mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
Compare commits
4 Commits
codex/cove
...
feat/payme
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744b105355 | ||
|
|
8f2a24a73d | ||
|
|
a222ad0ba5 | ||
|
|
2fae5a2089 |
@@ -15,7 +15,7 @@ const { categories } = defineProps<{
|
||||
|
||||
const activeSection = ref(categories[0]?.value ?? '')
|
||||
|
||||
const HEADER_OFFSET_PX = -144
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET_PX,
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 64,
|
||||
"1": 104
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,10 +34,6 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The validation warning shown above the app mode run button. */
|
||||
public readonly validationWarning: Locator
|
||||
/** The action that opens graph mode errors from the validation warning. */
|
||||
public readonly viewErrorsInGraphButton: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
@@ -96,19 +92,13 @@ export class AppModeHelper {
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
|
||||
this.validationWarning = this.page.getByTestId(
|
||||
TestIds.linear.validationWarning
|
||||
)
|
||||
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
|
||||
TestIds.linear.viewErrorsInGraph
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
|
||||
@@ -172,9 +172,6 @@ export const TestIds = {
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
runButton: 'linear-run-button',
|
||||
validationWarning: 'linear-validation-warning',
|
||||
viewErrorsInGraph: 'linear-view-errors',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'App mode validation warning',
|
||||
{ tag: ['@ui', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens graph errors from the app mode validation warning', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.appMode.validationWarning).toBeHidden()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(appModeOverlay).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.validationWarning).toContainText(
|
||||
/Required input missing/i
|
||||
)
|
||||
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the app mode run button enabled when the warning is visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.runButton).toBeEnabled()
|
||||
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -16,10 +15,9 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
|
||||
toLinkId(1)
|
||||
)
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
@@ -17,9 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.linear.runButton)
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="viewErrorsInGraph"
|
||||
@click="seeErrors"
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
@@ -67,18 +67,31 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -239,7 +239,8 @@ const handleOpenPlanAndCreditsSettings = () => {
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
@@ -253,7 +254,7 @@ const handleOpenPartnerNodesInfo = () => {
|
||||
}
|
||||
|
||||
const handleUpgradeToAddCredits = () => {
|
||||
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
function handleClick() {
|
||||
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
CreateTopupResponse,
|
||||
CurrentTeamCreditStop,
|
||||
PaymentMethodCapability,
|
||||
Plan,
|
||||
PreviewSubscribeOptions,
|
||||
PreviewSubscribeResponse,
|
||||
@@ -38,6 +38,7 @@ export interface BalanceInfo {
|
||||
effectiveBalanceMicros?: number
|
||||
prepaidBalanceMicros?: number
|
||||
cloudCreditBalanceMicros?: number
|
||||
pendingChargesMicros?: number
|
||||
}
|
||||
|
||||
export interface BillingActions {
|
||||
@@ -76,10 +77,9 @@ export interface BillingActions {
|
||||
*/
|
||||
requireActiveSubscription: () => Promise<void>
|
||||
/**
|
||||
* Shows the subscription dialog. Pass a reason so the paywall open and any
|
||||
* downstream checkout stay attributed to the triggering product moment.
|
||||
* Shows the subscription dialog.
|
||||
*/
|
||||
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
|
||||
showSubscriptionDialog: () => void
|
||||
}
|
||||
|
||||
export interface BillingState {
|
||||
@@ -103,6 +103,10 @@ export interface BillingState {
|
||||
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
|
||||
tier: ComputedRef<SubscriptionTier | null>
|
||||
renewalDate: ComputedRef<string | null>
|
||||
/** Workspace-only: what payment methods can be used for recurring charges. */
|
||||
paymentMethodCapability: ComputedRef<PaymentMethodCapability | null>
|
||||
/** Workspace-only: type slug of the default payment method on file. */
|
||||
defaultPaymentMethodType: ComputedRef<string | null>
|
||||
}
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
SubscribeOptions
|
||||
@@ -159,6 +158,12 @@ function useBillingContextInternal(): BillingContext {
|
||||
)
|
||||
const tier = computed(() => toValue(activeContext.value.tier))
|
||||
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
|
||||
const paymentMethodCapability = computed(() =>
|
||||
toValue(activeContext.value.paymentMethodCapability)
|
||||
)
|
||||
const defaultPaymentMethodType = computed(() =>
|
||||
toValue(activeContext.value.defaultPaymentMethodType)
|
||||
)
|
||||
|
||||
function getMaxSeats(tierKey: TierKey): number {
|
||||
if (type.value === 'legacy') return 1
|
||||
@@ -282,8 +287,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.requireActiveSubscription()
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
|
||||
return activeContext.value.showSubscriptionDialog(options)
|
||||
function showSubscriptionDialog() {
|
||||
return activeContext.value.showSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -304,6 +309,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
paymentMethodCapability,
|
||||
defaultPaymentMethodType,
|
||||
getMaxSeats,
|
||||
|
||||
initialize,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
@@ -92,6 +91,9 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
const renewalDate = computed(
|
||||
() => legacySubscriptionStatus.value?.renewal_date ?? null
|
||||
)
|
||||
// Payment method capability is workspace-only; legacy always reports null.
|
||||
const paymentMethodCapability = computed(() => null)
|
||||
const defaultPaymentMethodType = computed(() => null)
|
||||
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
const plans = computed(() => [])
|
||||
@@ -190,12 +192,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
|
||||
legacyShowSubscriptionDialog(options)
|
||||
function showSubscriptionDialog(): void {
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -215,6 +217,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
paymentMethodCapability,
|
||||
defaultPaymentMethodType,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
|
||||
@@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile',
|
||||
SETTLE_ENDPOINT_ENABLED = 'settle_endpoint_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,6 +182,13 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.signup_turnstile,
|
||||
'off'
|
||||
)
|
||||
},
|
||||
get settleEndpointEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.SETTLE_ENDPOINT_ENABLED,
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
storeSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMock
|
||||
}))
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
ui: {
|
||||
settings: {
|
||||
dispatchChange: vi.fn()
|
||||
}
|
||||
},
|
||||
rootGraph: {
|
||||
events: new EventTarget(),
|
||||
nodes: []
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
function createSelectedCanvas() {
|
||||
const graph = new LGraph()
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
|
||||
const canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_events: true,
|
||||
skip_render: true
|
||||
})
|
||||
const node = new LGraphNode('Selected Node')
|
||||
graph.add(node)
|
||||
canvas.selectedItems.add(node)
|
||||
node.selected = true
|
||||
|
||||
return { canvas, node }
|
||||
}
|
||||
|
||||
describe('useViewErrorsInGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
apiMock.getSettings.mockResolvedValue({})
|
||||
apiMock.storeSetting.mockResolvedValue(undefined)
|
||||
apiMock.storeSettings.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('opens graph errors and clears app-mode error UI state', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { canvas, node } = createSelectedCanvas()
|
||||
workflowStore.activeWorkflow = {
|
||||
activeMode: 'app'
|
||||
} as typeof workflowStore.activeWorkflow
|
||||
canvasStore.canvas = canvas
|
||||
canvasStore.selectedItems = [node]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
useViewErrorsInGraph().viewErrorsInGraph()
|
||||
|
||||
expect(node.selected).toBe(false)
|
||||
expect(canvasStore.linearMode).toBe(false)
|
||||
expect(canvasStore.selectedItems).toEqual([])
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('opens graph errors when the canvas is not initialized', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
canvasStore.canvas = null
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
|
||||
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
export function useViewErrorsInGraph() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
function viewErrorsInGraph() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
return { viewErrorsInGraph }
|
||||
}
|
||||
@@ -2506,7 +2506,11 @@
|
||||
"topupFailed": "Top-up failed",
|
||||
"topupTimeout": "Top-up verification timed out",
|
||||
"cancelFailed": "Failed to cancel subscription",
|
||||
"cancelTimeout": "Subscription cancellation timed out"
|
||||
"cancelTimeout": "Subscription cancellation timed out",
|
||||
"payOwedProcessing": "Processing payment…",
|
||||
"payOwedSuccess": "Payment processed successfully",
|
||||
"payOwedFailed": "Payment failed",
|
||||
"payOwedTimeout": "Payment verification timed out"
|
||||
},
|
||||
"subscription": {
|
||||
"plansForWorkspace": "Plans for {workspace}",
|
||||
@@ -4489,5 +4493,37 @@
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
},
|
||||
"billing": {
|
||||
"spendLimit": {
|
||||
"addPaymentMethodTitle": "Add a payment method",
|
||||
"paymentFailedTitle": "Your automatic payment failed",
|
||||
"oneTimeOnlyInfo": "{method} can't be used for automatic top-ups — add a card, bank account, or Link",
|
||||
"addPaymentMethodCta": "Add a payment method",
|
||||
"updatePaymentMethodCta": "Update payment method",
|
||||
"orBuyManually": "Or buy credits manually",
|
||||
"capabilityError": "Unable to load payment method status. You can add a payment method below.",
|
||||
"defaultMethod": "Your current payment method",
|
||||
"methodLabels": {
|
||||
"alipay": "Alipay",
|
||||
"card": "Your card",
|
||||
"us_bank_account": "Your bank account",
|
||||
"link": "Link"
|
||||
}
|
||||
},
|
||||
"owedBalance": {
|
||||
"title": "Outstanding balance: {amount}",
|
||||
"addPaymentMethod": "Add a payment method",
|
||||
"addCardOrBank": "Add a card or bank account",
|
||||
"oneTimeOnlyHint": "Your current method can't be used to settle a balance — add a card, bank account, or Link",
|
||||
"payNow": "Pay now",
|
||||
"processing": "Processing payment…",
|
||||
"chargeAutomatic": "A charge will process automatically.",
|
||||
"unknownCapability": "Payment method status unavailable. Refresh to try again."
|
||||
},
|
||||
"consent": {
|
||||
"paymentMethodBody": "By adding a payment method, you authorize Comfy Org to automatically charge it — without further action from you at the time — for unpaid balances and usage overages, in the amount you owe at billing time. You can remove it at any time in {settingsLink} (an active subscription or unpaid balance may need to be resolved first).",
|
||||
"settingsLink": "Account Settings → Credits"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,6 @@ function handleClose() {
|
||||
}
|
||||
|
||||
function handleSubscribe() {
|
||||
showSubscriptionDialog({ reason: 'upload_model_upgrade' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -140,10 +140,7 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'creator',
|
||||
'monthly',
|
||||
{
|
||||
openInNewTab: false,
|
||||
paymentIntentSource: 'deep_link'
|
||||
}
|
||||
false
|
||||
)
|
||||
|
||||
// Shows loading affordances
|
||||
@@ -172,10 +169,7 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'creator',
|
||||
'monthly',
|
||||
{
|
||||
openInNewTab: false,
|
||||
paymentIntentSource: 'deep_link'
|
||||
}
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
@@ -186,8 +180,7 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
|
||||
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'team_700',
|
||||
'yearly',
|
||||
{ paymentIntentSource: 'deep_link' }
|
||||
'yearly'
|
||||
)
|
||||
// Team never goes through the personal checkout path
|
||||
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()
|
||||
|
||||
@@ -94,9 +94,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
return
|
||||
}
|
||||
isTeamCheckout.value = true
|
||||
await performTeamSubscriptionCheckout(stopId, billingCycle, {
|
||||
paymentIntentSource: 'deep_link'
|
||||
})
|
||||
await performTeamSubscriptionCheckout(stopId, billingCycle)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,10 +112,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
if (isActiveSubscription.value) {
|
||||
await accessBillingPortal(undefined, false)
|
||||
} else {
|
||||
await performSubscriptionCheckout(tierKeyParam, billingCycle, {
|
||||
openInNewTab: false,
|
||||
paymentIntentSource: 'deep_link'
|
||||
})
|
||||
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
|
||||
}
|
||||
}, reportError)
|
||||
|
||||
|
||||
@@ -10,12 +10,16 @@ import type { CurrentTeamCreditStop } from '@/platform/workspace/api/workspaceAp
|
||||
|
||||
type Balance = Pick<
|
||||
BalanceInfo,
|
||||
'amountMicros' | 'cloudCreditBalanceMicros' | 'prepaidBalanceMicros'
|
||||
>
|
||||
| 'amountMicros'
|
||||
| 'cloudCreditBalanceMicros'
|
||||
| 'prepaidBalanceMicros'
|
||||
| 'pendingChargesMicros'
|
||||
> & { currency?: string }
|
||||
type Subscription = Pick<SubscriptionInfo, 'duration' | 'renewalDate'> & {
|
||||
tier: SubscriptionInfo['tier'] | 'TEAM'
|
||||
}
|
||||
type TeamStop = CurrentTeamCreditStop
|
||||
type PaymentMethodCapability = 'none' | 'one_time_only' | 'reusable'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
balance: null as Balance | null,
|
||||
@@ -25,12 +29,20 @@ const state = vi.hoisted(() => ({
|
||||
currentTeamCreditStop: null as TeamStop | null,
|
||||
isLoading: false,
|
||||
canTopUp: true,
|
||||
paymentMethodCapability: null as PaymentMethodCapability | null,
|
||||
settleEndpointEnabled: false,
|
||||
isPayingOwed: false,
|
||||
fetchBalance: vi.fn(),
|
||||
fetchStatus: vi.fn(),
|
||||
showPricingTable: vi.fn(),
|
||||
showTopUpCreditsDialog: vi.fn(),
|
||||
trackAddApiCreditButtonClicked: vi.fn(),
|
||||
toastErrorHandler: vi.fn()
|
||||
toastErrorHandler: vi.fn(),
|
||||
toastAdd: vi.fn(),
|
||||
initiateAddPaymentMethod: vi.fn(),
|
||||
settleOwedBalance: vi.fn(),
|
||||
startOperation: vi.fn(),
|
||||
clearOperation: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
@@ -57,11 +69,46 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
isFreeTier: computed(() => state.isFreeTier),
|
||||
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
|
||||
isLoading: computed(() => state.isLoading),
|
||||
paymentMethodCapability: computed(() => state.paymentMethodCapability),
|
||||
fetchBalance: state.fetchBalance,
|
||||
fetchStatus: state.fetchStatus
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get settleEndpointEnabled() {
|
||||
return state.settleEndpointEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
initiateAddPaymentMethod: (...args: unknown[]) =>
|
||||
state.initiateAddPaymentMethod(...args),
|
||||
settleOwedBalance: (...args: unknown[]) => state.settleOwedBalance(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
|
||||
useBillingOperationStore: () => ({
|
||||
get isPayingOwed() {
|
||||
return state.isPayingOwed
|
||||
},
|
||||
startOperation: (...args: unknown[]) => state.startOperation(...args),
|
||||
clearOperation: (...args: unknown[]) => state.clearOperation(...args)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
add: (...args: unknown[]) => state.toastAdd(...args)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({ canTopUp: state.canTopUp }))
|
||||
@@ -92,6 +139,11 @@ const i18n = createI18n({
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
error: 'Error',
|
||||
warning: 'Warning',
|
||||
unknownError: 'An unknown error occurred'
|
||||
},
|
||||
subscription: {
|
||||
totalCredits: 'Total credits',
|
||||
remaining: 'remaining',
|
||||
@@ -116,7 +168,25 @@ const i18n = createI18n({
|
||||
outOfCreditsTitleNoDate: "You're out of credits",
|
||||
outOfCreditsDescription: 'Add more credits to continue generating.',
|
||||
addCredits: 'Add credits',
|
||||
upgradeToAddCredits: 'Upgrade to add credits'
|
||||
upgradeToAddCredits: 'Upgrade to add credits',
|
||||
preview: {
|
||||
paymentPopupBlocked:
|
||||
'Popup blocked. Please allow popups and try again.'
|
||||
}
|
||||
},
|
||||
billing: {
|
||||
owedBalance: {
|
||||
title: 'Outstanding balance: {amount}',
|
||||
addPaymentMethod: 'Add a payment method',
|
||||
addCardOrBank: 'Add a card or bank account',
|
||||
oneTimeOnlyHint:
|
||||
"Your current method can't be used to settle a balance — add a card, bank account, or Link",
|
||||
chargeAutomatic: 'A charge will process automatically.',
|
||||
payNow: 'Pay now',
|
||||
processing: 'Processing payment…',
|
||||
unknownCapability:
|
||||
'Payment method status unavailable. Refresh to try again.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,11 +201,17 @@ function renderTile(props: Record<string, unknown> = {}) {
|
||||
stubs: {
|
||||
Button: {
|
||||
template:
|
||||
'<button v-bind="$attrs" :data-variant="variant" :disabled="loading" @click="$emit(\'click\')"><slot/></button>',
|
||||
props: ['variant', 'size', 'loading'],
|
||||
'<button v-bind="$attrs" :data-variant="variant" :disabled="disabled || loading" @click="$emit(\'click\')"><slot/></button>',
|
||||
props: ['variant', 'size', 'loading', 'disabled'],
|
||||
emits: ['click']
|
||||
},
|
||||
Skeleton: { template: '<div role="status" aria-label="Loading"></div>' }
|
||||
Skeleton: {
|
||||
template: '<div role="status" aria-label="Loading"></div>'
|
||||
},
|
||||
SubscriptionTermsNote: {
|
||||
template: '<p data-testid="terms-note" :data-context="context"></p>',
|
||||
props: ['context']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -165,6 +241,9 @@ describe('CreditsTile', () => {
|
||||
state.currentTeamCreditStop = null
|
||||
state.isLoading = false
|
||||
state.canTopUp = true
|
||||
state.paymentMethodCapability = null
|
||||
state.settleEndpointEnabled = false
|
||||
state.isPayingOwed = false
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -370,4 +449,138 @@ describe('CreditsTile', () => {
|
||||
expect(state.toastErrorHandler).toHaveBeenCalledWith(failure)
|
||||
)
|
||||
})
|
||||
|
||||
describe('owed balance notice', () => {
|
||||
it('hides the notice when pendingChargesMicros is null', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: undefined }
|
||||
renderTile()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the notice when pendingChargesMicros is zero', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: 0 }
|
||||
renderTile()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the notice when pendingChargesMicros is negative', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: -100 }
|
||||
renderTile()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the notice with a formatted amount when pendingChargesMicros is positive', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: 5_000_000 }
|
||||
state.paymentMethodCapability = 'reusable'
|
||||
renderTile()
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert.textContent).toContain('$5.00')
|
||||
})
|
||||
|
||||
it('shows "Add a payment method" CTA and terms note for capability=none', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
|
||||
state.paymentMethodCapability = 'none'
|
||||
renderTile()
|
||||
expect(screen.getByText('Add a payment method')).toBeTruthy()
|
||||
const termsNote = screen.getByTestId('terms-note')
|
||||
expect(termsNote.dataset.context).toBe('payment_method')
|
||||
expect(screen.queryByText('Pay now')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows "Add a card or bank account" CTA, hint, and terms note for capability=one_time_only', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
|
||||
state.paymentMethodCapability = 'one_time_only'
|
||||
renderTile()
|
||||
expect(screen.getByText('Add a card or bank account')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Your current method can't be used to settle a balance — add a card, bank account, or Link"
|
||||
)
|
||||
).toBeTruthy()
|
||||
const termsNote = screen.getByTestId('terms-note')
|
||||
expect(termsNote.dataset.context).toBe('payment_method')
|
||||
expect(screen.queryByText('Pay now')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows an auto-charge message and no CTA for capability=reusable when settle flag is off', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
|
||||
state.paymentMethodCapability = 'reusable'
|
||||
state.settleEndpointEnabled = false
|
||||
renderTile()
|
||||
expect(
|
||||
screen.getByText('A charge will process automatically.')
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByText('Pay now')).toBeNull()
|
||||
expect(screen.queryByText('Add a payment method')).toBeNull()
|
||||
expect(screen.queryByTestId('terms-note')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows "Pay now" CTA for capability=reusable when settle flag is on', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
|
||||
state.paymentMethodCapability = 'reusable'
|
||||
state.settleEndpointEnabled = true
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('Pay now')
|
||||
expect(container.textContent).not.toContain('Add a payment method')
|
||||
})
|
||||
|
||||
it('shows "Processing payment…" and disables the Pay now button while isPayingOwed', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
|
||||
state.paymentMethodCapability = 'reusable'
|
||||
state.settleEndpointEnabled = true
|
||||
state.isPayingOwed = true
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('Processing payment…')
|
||||
const btn = screen.getByRole('button', { name: /Processing payment/i })
|
||||
expect(btn.getAttribute('disabled')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to USD when balance.currency is an empty string', () => {
|
||||
activeProSubscription()
|
||||
state.balance = {
|
||||
amountMicros: 500,
|
||||
pendingChargesMicros: 5_000_000,
|
||||
currency: ''
|
||||
}
|
||||
state.paymentMethodCapability = 'reusable'
|
||||
renderTile()
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert.textContent).toContain('$5.00')
|
||||
})
|
||||
|
||||
it('hides CTAs when canTopUp is false even when balance is owed', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
|
||||
state.paymentMethodCapability = 'none'
|
||||
state.canTopUp = false
|
||||
renderTile()
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert.textContent).toContain('Outstanding balance')
|
||||
expect(screen.queryByText('Add a payment method')).toBeNull()
|
||||
expect(screen.queryByTestId('terms-note')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a neutral message when paymentMethodCapability is null', () => {
|
||||
activeProSubscription()
|
||||
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
|
||||
state.paymentMethodCapability = null
|
||||
state.canTopUp = true
|
||||
renderTile()
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert.textContent).toContain(
|
||||
'Payment method status unavailable. Refresh to try again.'
|
||||
)
|
||||
expect(screen.queryByText('Pay now')).toBeNull()
|
||||
expect(screen.queryByText('Add a payment method')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
|
||||
>
|
||||
<Button
|
||||
ref="refreshButtonRef"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
@@ -143,6 +144,94 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Owed balance notice: shown only when there is a positive pending charge -->
|
||||
<div
|
||||
v-if="owedBalanceAmount !== null"
|
||||
role="alert"
|
||||
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
|
||||
>
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--alert-triangle] size-4 shrink-0 text-base-foreground"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-base-foreground">{{
|
||||
$t('billing.owedBalance.title', { amount: owedBalanceAmount })
|
||||
}}</span>
|
||||
|
||||
<!-- one_time_only hint -->
|
||||
<span
|
||||
v-if="paymentMethodCapability === 'one_time_only'"
|
||||
class="text-muted"
|
||||
>{{ $t('billing.owedBalance.oneTimeOnlyHint') }}</span
|
||||
>
|
||||
|
||||
<!-- reusable + flag off: read-only message -->
|
||||
<span
|
||||
v-if="
|
||||
paymentMethodCapability === 'reusable' && !settleEndpointEnabled
|
||||
"
|
||||
class="text-muted"
|
||||
>{{ $t('billing.owedBalance.chargeAutomatic') }}</span
|
||||
>
|
||||
|
||||
<!-- consent note before add-payment-method CTA -->
|
||||
<SubscriptionTermsNote
|
||||
v-if="
|
||||
permissions.canTopUp &&
|
||||
(paymentMethodCapability === 'none' ||
|
||||
paymentMethodCapability === 'one_time_only')
|
||||
"
|
||||
context="payment_method"
|
||||
/>
|
||||
|
||||
<!-- CTA: none / one_time_only → add payment method -->
|
||||
<Button
|
||||
v-if="
|
||||
permissions.canTopUp &&
|
||||
(paymentMethodCapability === 'none' ||
|
||||
paymentMethodCapability === 'one_time_only')
|
||||
"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="w-fit"
|
||||
@click="handleOwedAddPaymentMethod"
|
||||
>
|
||||
{{
|
||||
paymentMethodCapability === 'none'
|
||||
? $t('billing.owedBalance.addPaymentMethod')
|
||||
: $t('billing.owedBalance.addCardOrBank')
|
||||
}}
|
||||
</Button>
|
||||
|
||||
<!-- CTA: reusable + flag on → Pay now -->
|
||||
<Button
|
||||
v-else-if="
|
||||
permissions.canTopUp &&
|
||||
paymentMethodCapability === 'reusable' &&
|
||||
settleEndpointEnabled
|
||||
"
|
||||
ref="payNowButtonRef"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="w-fit"
|
||||
:disabled="isPayingOwed || isPayingNow"
|
||||
@click="handlePayNow"
|
||||
>
|
||||
<span v-if="isPayingOwed || isPayingNow" role="status">{{
|
||||
$t('billing.owedBalance.processing')
|
||||
}}</span>
|
||||
<template v-else>{{ $t('billing.owedBalance.payNow') }}</template>
|
||||
</Button>
|
||||
|
||||
<!-- capability loading/unknown: show a neutral message -->
|
||||
<span
|
||||
v-if="permissions.canTopUp && paymentMethodCapability === null"
|
||||
class="text-muted"
|
||||
>{{ $t('billing.owedBalance.unknownCapability') }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showActionButton" class="flex flex-col gap-3">
|
||||
<Button
|
||||
v-if="isFreeTier"
|
||||
@@ -176,13 +265,14 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
@@ -193,7 +283,11 @@ import {
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import SubscriptionTermsNote from '@/platform/workspace/components/SubscriptionTermsNote.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { zeroState = false } = defineProps<{
|
||||
@@ -209,6 +303,7 @@ const {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
currentTeamCreditStop,
|
||||
paymentMethodCapability,
|
||||
fetchBalance,
|
||||
fetchStatus
|
||||
} = useBillingContext()
|
||||
@@ -225,6 +320,101 @@ const { showPricingTable } = useSubscriptionDialog()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const { flags } = useFeatureFlags()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const settleEndpointEnabled = computed(() => flags.settleEndpointEnabled)
|
||||
|
||||
const owedBalanceAmount = computed(() => {
|
||||
const pendingMicros = balance.value?.pendingChargesMicros
|
||||
if (pendingMicros == null || pendingMicros <= 0) return null
|
||||
const rawCurrency = (balance.value?.currency ?? '').toUpperCase()
|
||||
const currency = /^[A-Z]{3}$/.test(rawCurrency) ? rawCurrency : 'USD'
|
||||
return (pendingMicros / 1_000_000).toLocaleString(locale.value, {
|
||||
style: 'currency',
|
||||
currency
|
||||
})
|
||||
})
|
||||
|
||||
const isPayingOwed = computed(() => billingOperationStore.isPayingOwed)
|
||||
const isPayingNow = ref(false)
|
||||
|
||||
const payNowButtonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
const refreshButtonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
|
||||
let payNowOpId: string | null = null
|
||||
|
||||
async function handleOwedAddPaymentMethod() {
|
||||
try {
|
||||
const response = await workspaceApi.initiateAddPaymentMethod()
|
||||
const url = response.payment_method_url
|
||||
if (!new URL(url).hostname.endsWith('.stripe.com')) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.unknownError')
|
||||
})
|
||||
return
|
||||
}
|
||||
const win = window.open(url, '_blank', 'noopener,noreferrer')
|
||||
if (!win) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('subscription.preview.paymentPopupBlocked')
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePayNow() {
|
||||
if (isPayingOwed.value || isPayingNow.value) return
|
||||
isPayingNow.value = true
|
||||
|
||||
const idempotencyKey = crypto.randomUUID()
|
||||
|
||||
if (payNowOpId) {
|
||||
billingOperationStore.clearOperation(payNowOpId)
|
||||
payNowOpId = null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await workspaceApi.settleOwedBalance(idempotencyKey)
|
||||
payNowOpId = response.billing_op_id
|
||||
const operation = await billingOperationStore.startOperation(
|
||||
payNowOpId,
|
||||
'pay_owed'
|
||||
)
|
||||
|
||||
if (operation.status === 'succeeded') {
|
||||
await nextTick()
|
||||
const el = refreshButtonRef.value?.$el as HTMLElement | undefined
|
||||
el?.focus()
|
||||
} else {
|
||||
await nextTick()
|
||||
const el = payNowButtonRef.value?.$el as HTMLElement | undefined
|
||||
el?.focus()
|
||||
}
|
||||
} catch (err) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError')
|
||||
})
|
||||
await nextTick()
|
||||
const el = payNowButtonRef.value?.$el as HTMLElement | undefined
|
||||
el?.focus()
|
||||
} finally {
|
||||
isPayingNow.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
@@ -351,12 +541,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => {
|
||||
})
|
||||
|
||||
function handleAddCredits() {
|
||||
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
|
||||
telemetry?.trackAddApiCreditButtonClicked()
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
function handleUpgradeToAddCredits() {
|
||||
showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
showPricingTable()
|
||||
}
|
||||
|
||||
async function handleWindowFocus() {
|
||||
|
||||
@@ -5,8 +5,6 @@ import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
|
||||
import FreeTierDialogContent from './FreeTierDialogContent.vue'
|
||||
|
||||
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
|
||||
@@ -17,7 +15,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
function renderComponent(props?: { reason?: PaymentIntentSource }) {
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -25,7 +23,6 @@ function renderComponent(props?: { reason?: PaymentIntentSource }) {
|
||||
})
|
||||
|
||||
return render(FreeTierDialogContent, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
@@ -46,18 +43,4 @@ describe('FreeTierDialogContent', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the generic copy for intent reasons outside the credits variants', () => {
|
||||
mockRenewalDate.value = '2026-07-15T10:00:00Z'
|
||||
renderComponent({ reason: 'subscribe_to_run' })
|
||||
expect(
|
||||
screen.getByText('Your credits refresh on Jul 15, 2026.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('swaps to the out-of-credits copy without the refresh line', () => {
|
||||
mockRenewalDate.value = '2026-07-15T10:00:00Z'
|
||||
renderComponent({ reason: 'out_of_credits' })
|
||||
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="!isCreditsBlockedVariant"
|
||||
v-if="!reason || reason === 'subscription_required'"
|
||||
class="m-0 text-sm text-text-secondary"
|
||||
>
|
||||
{{
|
||||
@@ -65,7 +65,10 @@
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="!isCreditsBlockedVariant && formattedRenewalDate"
|
||||
v-if="
|
||||
(!reason || reason === 'subscription_required') &&
|
||||
formattedRenewalDate
|
||||
"
|
||||
class="m-0 text-sm text-text-secondary"
|
||||
>
|
||||
{{
|
||||
@@ -85,7 +88,7 @@
|
||||
@click="$emit('upgrade')"
|
||||
>
|
||||
{{
|
||||
isCreditsBlockedVariant
|
||||
reason === 'out_of_credits' || reason === 'top_up_blocked'
|
||||
? $t('subscription.freeTier.upgradeCta')
|
||||
: $t('subscription.freeTier.subscribeCta')
|
||||
}}
|
||||
@@ -100,12 +103,12 @@ import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
const { reason } = defineProps<{
|
||||
reason?: PaymentIntentSource
|
||||
defineProps<{
|
||||
reason?: SubscriptionDialogReason
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -126,10 +129,4 @@ const formattedRenewalDate = computed(() => {
|
||||
})
|
||||
|
||||
const freeTierCredits = computed(() => getTierCredits('free'))
|
||||
|
||||
// Only these two variants replace the generic free-tier copy; any other
|
||||
// intent reason (subscribe_to_run, deep_link, ...) keeps the default pitch.
|
||||
const isCreditsBlockedVariant = computed(
|
||||
() => reason === 'out_of_credits' || reason === 'top_up_blocked'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -261,7 +261,6 @@ describe('PricingTable', () => {
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change',
|
||||
checkout_attempt_id: expect.any(String),
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
|
||||
@@ -342,7 +341,6 @@ describe('PricingTable', () => {
|
||||
expect(
|
||||
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use the latest userId value when it changes after mount', async () => {
|
||||
@@ -368,7 +366,6 @@ describe('PricingTable', () => {
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change',
|
||||
checkout_attempt_id: expect.any(String),
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -277,19 +277,13 @@ import type {
|
||||
TierKey,
|
||||
TierPricing
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
recordPendingSubscriptionCheckoutAttempt,
|
||||
withPendingCheckoutAttemptId
|
||||
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
|
||||
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
CheckoutAttributionMetadata,
|
||||
PaymentIntentSource
|
||||
} from '@/platform/telemetry/types'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
@@ -327,10 +321,6 @@ interface PricingTierConfig {
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
const { reason } = defineProps<{
|
||||
reason?: PaymentIntentSource
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
chooseTeamWorkspace: []
|
||||
}>()
|
||||
@@ -473,17 +463,16 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
} as const
|
||||
const previousPlan = currentPlanDescriptor.value
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
const beginCheckoutMetadata = userId.value
|
||||
? {
|
||||
user_id: userId.value,
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
checkout_type: 'change' as const,
|
||||
...(reason ? { payment_intent_source: reason } : {}),
|
||||
...checkoutAttribution,
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
|
||||
}
|
||||
: null
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
checkout_type: 'change',
|
||||
...checkoutAttribution,
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
|
||||
})
|
||||
}
|
||||
// Pass the target tier to create a deep link to subscription update confirmation
|
||||
const checkoutTier = getCheckoutTier(
|
||||
targetPlan.tierKey,
|
||||
@@ -498,39 +487,29 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
|
||||
if (downgrade) {
|
||||
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
|
||||
const didOpenPortal = await accessBillingPortal()
|
||||
if (didOpenPortal && beginCheckoutMetadata) {
|
||||
telemetry?.trackBeginCheckout(beginCheckoutMetadata)
|
||||
}
|
||||
await accessBillingPortal()
|
||||
} else {
|
||||
const didOpenPortal = await accessBillingPortal(checkoutTier)
|
||||
if (!didOpenPortal) {
|
||||
return
|
||||
}
|
||||
|
||||
const pendingAttempt = recordPendingSubscriptionCheckoutAttempt({
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
checkout_type: 'change',
|
||||
payment_intent_source: reason,
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
|
||||
...(previousPlan
|
||||
? { previous_cycle: previousPlan.billingCycle }
|
||||
: {})
|
||||
})
|
||||
if (beginCheckoutMetadata) {
|
||||
telemetry?.trackBeginCheckout(
|
||||
withPendingCheckoutAttemptId(
|
||||
beginCheckoutMetadata,
|
||||
pendingAttempt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await performSubscriptionCheckout(tierKey, currentBillingCycle.value, {
|
||||
paymentIntentSource: reason
|
||||
})
|
||||
await performSubscriptionCheckout(
|
||||
tierKey,
|
||||
currentBillingCycle.value,
|
||||
true
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -56,7 +56,7 @@ const handleSubscribe = () => {
|
||||
current_tier: tier.value?.toLowerCase()
|
||||
})
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog({ reason: 'subscribe_now_button' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -54,6 +54,6 @@ function handleSubscribeToRun() {
|
||||
trackRunButton({ subscribe_to_run: true })
|
||||
}
|
||||
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,9 +48,7 @@
|
||||
v-if="isActiveSubscription"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="
|
||||
showSubscriptionDialog({ reason: 'settings_billing_panel' })
|
||||
"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
|
||||
@@ -33,11 +33,7 @@
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<PricingTable
|
||||
:reason
|
||||
class="flex-1"
|
||||
@choose-team-workspace="handleChooseTeam"
|
||||
/>
|
||||
<PricingTable class="flex-1" @choose-team-workspace="handleChooseTeam" />
|
||||
|
||||
<!-- Contact and Enterprise Links -->
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
@@ -161,11 +157,11 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
|
||||
const { onClose, reason, onChooseTeam } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: PaymentIntentSource
|
||||
reason?: SubscriptionDialogReason
|
||||
onChooseTeam?: () => void
|
||||
}>()
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ export function useAccountPreconditionDialog() {
|
||||
)
|
||||
return
|
||||
case 'subscription':
|
||||
void dialogService.showSubscriptionRequiredDialog({
|
||||
reason: 'subscription_required'
|
||||
})
|
||||
void dialogService.showSubscriptionRequiredDialog()
|
||||
return
|
||||
case 'credits':
|
||||
void dialogService.showTopUpCreditsDialog({
|
||||
|
||||
@@ -55,6 +55,12 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockTrackSubscription = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
|
||||
}))
|
||||
|
||||
describe('usePricingTableUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -90,6 +96,9 @@ describe('usePricingTableUrlLoader', () => {
|
||||
reason: 'deep_link',
|
||||
planMode: undefined
|
||||
})
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
|
||||
reason: 'deep_link'
|
||||
})
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
})
|
||||
|
||||
@@ -141,6 +150,7 @@ describe('usePricingTableUrlLoader', () => {
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies, strips, and clears together when the user is not eligible', async () => {
|
||||
@@ -151,6 +161,7 @@ describe('usePricingTableUrlLoader', () => {
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({
|
||||
query: { other: 'param' }
|
||||
})
|
||||
@@ -219,6 +230,7 @@ describe('usePricingTableUrlLoader', () => {
|
||||
)
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'pricing'
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
@@ -61,6 +62,7 @@ export function usePricingTableUrlLoader() {
|
||||
const planMode =
|
||||
param === 'team' || param === 'personal' ? param : undefined
|
||||
|
||||
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
|
||||
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { t } from '@/i18n'
|
||||
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -237,7 +237,14 @@ function useSubscriptionInternal() {
|
||||
})
|
||||
}, reportError)
|
||||
|
||||
const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => {
|
||||
const showSubscriptionDialog = (options?: {
|
||||
reason?: SubscriptionDialogReason
|
||||
}) => {
|
||||
useTelemetry()?.trackSubscription('modal_opened', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase(),
|
||||
reason: options?.reason
|
||||
})
|
||||
|
||||
void showSubscriptionRequiredDialog(options)
|
||||
}
|
||||
|
||||
@@ -270,7 +277,7 @@ function useSubscriptionInternal() {
|
||||
await fetchSubscriptionStatus()
|
||||
|
||||
if (!isSubscribedOrIsNotCloud.value) {
|
||||
showSubscriptionDialog({ reason: 'subscription_required' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,23 +39,15 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
}))
|
||||
|
||||
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
|
||||
const {
|
||||
mockIsCloud,
|
||||
mockTrackHelpResourceClicked,
|
||||
mockTrackAddApiCreditButtonClicked
|
||||
} = vi.hoisted(() => ({
|
||||
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockTrackHelpResourceClicked: vi.fn(),
|
||||
mockTrackAddApiCreditButtonClicked: vi.fn()
|
||||
mockTrackHelpResourceClicked: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () =>
|
||||
mockIsCloud.value
|
||||
? {
|
||||
trackHelpResourceClicked: mockTrackHelpResourceClicked,
|
||||
trackAddApiCreditButtonClicked: mockTrackAddApiCreditButtonClicked
|
||||
}
|
||||
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
|
||||
: null
|
||||
}))
|
||||
|
||||
@@ -77,9 +69,6 @@ describe('useSubscriptionActions', () => {
|
||||
const { handleAddApiCredits } = useSubscriptionActions()
|
||||
handleAddApiCredits()
|
||||
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
|
||||
expect(mockTrackAddApiCreditButtonClicked).toHaveBeenCalledWith({
|
||||
source: 'settings_billing_panel'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ export function useSubscriptionActions() {
|
||||
})
|
||||
|
||||
const handleAddApiCredits = () => {
|
||||
telemetry?.trackAddApiCreditButtonClicked({
|
||||
source: 'settings_billing_panel'
|
||||
})
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@ import { useSubscriptionDialog } from './useSubscriptionDialog'
|
||||
const mockCloseDialog = vi.fn()
|
||||
const mockShowLayoutDialog = vi.fn()
|
||||
const mockShowTeamWorkspacesDialog = vi.fn()
|
||||
const mockTrackSubscription = vi.hoisted(() => vi.fn())
|
||||
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
|
||||
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
|
||||
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
|
||||
@@ -62,15 +60,10 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isFreeTier: mockIsFreeTier,
|
||||
isLegacyTeamPlan: mockIsLegacyTeamPlan,
|
||||
tier: mockTier
|
||||
isLegacyTeamPlan: mockIsLegacyTeamPlan
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: {
|
||||
@@ -87,7 +80,6 @@ describe('useSubscriptionDialog', () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockTier.value = 'FREE'
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsLegacyTeamPlan.value = false
|
||||
mockCanManageSubscription.value = true
|
||||
@@ -206,51 +198,6 @@ describe('useSubscriptionDialog', () => {
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props.initialPlanMode).toBe('team')
|
||||
})
|
||||
|
||||
it('tracks modal_opened with the caller reason and current tier', () => {
|
||||
mockTier.value = 'STANDARD'
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
|
||||
current_tier: 'standard',
|
||||
reason: 'upgrade_to_add_credits'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks modal_opened on the workspace (unified) path too', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'subscribe_to_run' })
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith(
|
||||
'modal_opened',
|
||||
expect.objectContaining({ reason: 'subscribe_to_run' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not track modal_opened for the inactive member dialog', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockCanManageSubscription.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'subscribe_to_run' })
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not track on non-cloud', () => {
|
||||
mockIsCloud.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'subscribe_to_run' })
|
||||
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('show', () => {
|
||||
@@ -288,20 +235,6 @@ describe('useSubscriptionDialog', () => {
|
||||
expect.objectContaining({ key: 'subscription-required' })
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks modal_opened with the reason for the free-tier dialog', () => {
|
||||
mockIsFreeTier.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { show } = useSubscriptionDialog()
|
||||
|
||||
show({ reason: 'out_of_credits' })
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith(
|
||||
'modal_opened',
|
||||
expect.objectContaining({ reason: 'out_of_credits' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startTeamWorkspaceUpgradeFlow', () => {
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
@@ -13,8 +11,14 @@ const DIALOG_KEY = 'subscription-required'
|
||||
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
|
||||
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
|
||||
|
||||
export interface SubscriptionDialogOptions {
|
||||
reason?: PaymentIntentSource
|
||||
export type SubscriptionDialogReason =
|
||||
| 'subscription_required'
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
| 'deep_link'
|
||||
|
||||
interface SubscriptionDialogOptions {
|
||||
reason?: SubscriptionDialogReason
|
||||
/**
|
||||
* Forces the unified pricing dialog to open on a specific plan tab,
|
||||
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
|
||||
@@ -34,17 +38,6 @@ export const useSubscriptionDialog = () => {
|
||||
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
|
||||
}
|
||||
|
||||
// Fired here — the choke point every paywall/pricing dialog variant passes
|
||||
// through — so both the legacy and workspace billing paths emit it.
|
||||
function trackModalOpened(reason?: PaymentIntentSource) {
|
||||
// Resolved lazily to avoid the useBillingContext import cycle (see below).
|
||||
const { tier } = useBillingContext()
|
||||
useTelemetry()?.trackSubscription('modal_opened', {
|
||||
current_tier: tier.value?.toLowerCase(),
|
||||
reason
|
||||
})
|
||||
}
|
||||
|
||||
function showPricingTable(options?: SubscriptionDialogOptions) {
|
||||
if (!isCloud) return
|
||||
|
||||
@@ -78,8 +71,6 @@ export const useSubscriptionDialog = () => {
|
||||
return
|
||||
}
|
||||
|
||||
trackModalOpened(options?.reason)
|
||||
|
||||
// Shared dialog shell styling for both variants.
|
||||
const dialogComponentProps = {
|
||||
style: 'width: min(1328px, 95vw); max-height: 958px;',
|
||||
@@ -176,8 +167,6 @@ export const useSubscriptionDialog = () => {
|
||||
// (not at composable setup) to avoid the useBillingContext import cycle.
|
||||
const { isFreeTier } = useBillingContext()
|
||||
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
|
||||
trackModalOpened(options?.reason)
|
||||
|
||||
const component = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
|
||||
@@ -247,7 +236,7 @@ export const useSubscriptionDialog = () => {
|
||||
sessionStorage.removeItem(RESUME_PRICING_KEY)
|
||||
|
||||
if (!workspaceStore.isInPersonalWorkspace) {
|
||||
showPricingTable({ reason: 'team_upgrade_resume' })
|
||||
showPricingTable()
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage may be unavailable
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clearPendingSubscriptionCheckoutAttempt,
|
||||
consumePendingSubscriptionCheckoutSuccess,
|
||||
recordPendingSubscriptionCheckoutAttempt
|
||||
} from './subscriptionCheckoutTracker'
|
||||
|
||||
const activeProStatus = {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
} as const
|
||||
|
||||
describe('subscriptionCheckoutTracker', () => {
|
||||
beforeEach(() => {
|
||||
clearPendingSubscriptionCheckoutAttempt()
|
||||
})
|
||||
|
||||
it('round-trips payment_intent_source from attempt to success metadata', () => {
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
|
||||
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
tier: 'pro',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits payment_intent_source when the attempt had none', () => {
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
|
||||
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
|
||||
|
||||
expect(metadata).not.toBeNull()
|
||||
expect(metadata).not.toHaveProperty('payment_intent_source')
|
||||
})
|
||||
})
|
||||
@@ -7,12 +7,7 @@ import type {
|
||||
TierKey
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type {
|
||||
BeginCheckoutMetadata,
|
||||
PaymentIntentSource,
|
||||
SubscriptionCheckoutType,
|
||||
SubscriptionSuccessMetadata
|
||||
} from '@/platform/telemetry/types'
|
||||
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
|
||||
|
||||
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
|
||||
const VALID_TIER_KEYS = new Set<TierKey>([
|
||||
@@ -28,6 +23,7 @@ export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
|
||||
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
|
||||
'comfy:subscription-checkout-attempt-changed'
|
||||
|
||||
type CheckoutType = 'new' | 'change'
|
||||
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
|
||||
|
||||
interface SubscriptionStatusSnapshot {
|
||||
@@ -36,24 +32,22 @@ interface SubscriptionStatusSnapshot {
|
||||
subscription_duration?: SubscriptionDuration | null
|
||||
}
|
||||
|
||||
export interface PendingSubscriptionCheckoutAttempt {
|
||||
interface PendingSubscriptionCheckoutAttempt {
|
||||
attempt_id: string
|
||||
started_at_ms: number
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
checkout_type: CheckoutType
|
||||
previous_tier?: TierKey
|
||||
previous_cycle?: BillingCycle
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
}
|
||||
|
||||
interface PendingSubscriptionCheckoutAttemptInput {
|
||||
interface RecordPendingSubscriptionCheckoutAttemptInput {
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
checkout_type: CheckoutType
|
||||
previous_tier?: TierKey
|
||||
previous_cycle?: BillingCycle
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
}
|
||||
|
||||
const dispatchPendingCheckoutChangeEvent = () => {
|
||||
@@ -174,9 +168,6 @@ const normalizeAttempt = (
|
||||
...(candidate.previous_cycle === 'monthly' ||
|
||||
candidate.previous_cycle === 'yearly'
|
||||
? { previous_cycle: candidate.previous_cycle }
|
||||
: {}),
|
||||
...(typeof candidate.payment_intent_source === 'string'
|
||||
? { payment_intent_source: candidate.payment_intent_source }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -233,27 +224,20 @@ const getPendingSubscriptionCheckoutAttempt =
|
||||
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
|
||||
getPendingSubscriptionCheckoutAttempt() !== null
|
||||
|
||||
export const createPendingSubscriptionCheckoutAttempt = (
|
||||
input: PendingSubscriptionCheckoutAttemptInput
|
||||
export const recordPendingSubscriptionCheckoutAttempt = (
|
||||
input: RecordPendingSubscriptionCheckoutAttemptInput
|
||||
): PendingSubscriptionCheckoutAttempt => {
|
||||
return {
|
||||
const storage = getStorage()
|
||||
const attempt: PendingSubscriptionCheckoutAttempt = {
|
||||
attempt_id: createAttemptId(),
|
||||
started_at_ms: Date.now(),
|
||||
tier: input.tier,
|
||||
cycle: input.cycle,
|
||||
checkout_type: input.checkout_type,
|
||||
...(input.previous_tier ? { previous_tier: input.previous_tier } : {}),
|
||||
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}),
|
||||
...(input.payment_intent_source
|
||||
? { payment_intent_source: input.payment_intent_source }
|
||||
: {})
|
||||
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export const persistPendingSubscriptionCheckoutAttempt = (
|
||||
attempt: PendingSubscriptionCheckoutAttempt
|
||||
): PendingSubscriptionCheckoutAttempt => {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return attempt
|
||||
}
|
||||
@@ -271,21 +255,6 @@ export const persistPendingSubscriptionCheckoutAttempt = (
|
||||
return attempt
|
||||
}
|
||||
|
||||
export const recordPendingSubscriptionCheckoutAttempt = (
|
||||
input: PendingSubscriptionCheckoutAttemptInput
|
||||
): PendingSubscriptionCheckoutAttempt =>
|
||||
persistPendingSubscriptionCheckoutAttempt(
|
||||
createPendingSubscriptionCheckoutAttempt(input)
|
||||
)
|
||||
|
||||
export const withPendingCheckoutAttemptId = (
|
||||
metadata: BeginCheckoutMetadata,
|
||||
attempt: PendingSubscriptionCheckoutAttempt
|
||||
): BeginCheckoutMetadata => ({
|
||||
...metadata,
|
||||
checkout_attempt_id: attempt.attempt_id
|
||||
})
|
||||
|
||||
const didAttemptSucceed = (
|
||||
attempt: PendingSubscriptionCheckoutAttempt,
|
||||
status: SubscriptionStatusSnapshot
|
||||
@@ -318,9 +287,6 @@ export const consumePendingSubscriptionCheckoutSuccess = (
|
||||
cycle: attempt.cycle,
|
||||
checkout_type: attempt.checkout_type,
|
||||
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
|
||||
...(attempt.payment_intent_source
|
||||
? { payment_intent_source: attempt.payment_intent_source }
|
||||
: {}),
|
||||
value,
|
||||
currency: 'USD',
|
||||
ecommerce: {
|
||||
|
||||
@@ -132,14 +132,13 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'yearly')
|
||||
await performSubscriptionCheckout('pro', 'yearly', true)
|
||||
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-123',
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
checkout_attempt_id: expect.any(String),
|
||||
ga_client_id: 'ga-client-id',
|
||||
ga_session_id: 'ga-session-id',
|
||||
ga_session_number: 'ga-session-number',
|
||||
@@ -151,12 +150,6 @@ describe('performSubscriptionCheckout', () => {
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
})
|
||||
const beginCheckoutMetadata =
|
||||
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
|
||||
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
|
||||
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
|
||||
JSON.parse(storedAttempt).attempt_id
|
||||
)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'/customers/cloud-subscription-checkout/pro-yearly'
|
||||
@@ -193,7 +186,7 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly')
|
||||
await performSubscriptionCheckout('pro', 'monthly', true)
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[SubscriptionCheckout] Failed to collect checkout attribution',
|
||||
@@ -210,43 +203,11 @@ describe('performSubscriptionCheckout', () => {
|
||||
user_id: 'user-123',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
checkout_attempt_id: expect.any(String)
|
||||
checkout_type: 'new'
|
||||
})
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('carries the payment intent source into begin_checkout and the pending attempt', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => window as unknown as Window)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly', {
|
||||
paymentIntentSource: 'out_of_credits'
|
||||
})
|
||||
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payment_intent_source: 'out_of_credits' })
|
||||
)
|
||||
const beginCheckoutMetadata =
|
||||
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
|
||||
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
|
||||
const pendingAttempt = JSON.parse(storedAttempt)
|
||||
expect(pendingAttempt).toMatchObject({
|
||||
payment_intent_source: 'out_of_credits'
|
||||
})
|
||||
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
|
||||
pendingAttempt.attempt_id
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses the latest userId when it changes after checkout starts', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi
|
||||
@@ -261,7 +222,7 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly')
|
||||
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
|
||||
|
||||
mockUserId.value = 'user-late'
|
||||
authHeader.resolve({ Authorization: 'Bearer test-token' })
|
||||
@@ -274,14 +235,13 @@ describe('performSubscriptionCheckout', () => {
|
||||
user_id: 'user-late',
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
checkout_attempt_id: expect.any(String)
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('does not persist the pending attempt when the checkout popup is blocked', async () => {
|
||||
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
@@ -290,18 +250,11 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly')
|
||||
await performSubscriptionCheckout('pro', 'monthly', true)
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
const storedAttempt = window.localStorage.getItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
)
|
||||
expect(storedAttempt).toBeNull()
|
||||
expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkout_attempt_id: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(
|
||||
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,19 +4,12 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
createPendingSubscriptionCheckoutAttempt,
|
||||
persistPendingSubscriptionCheckoutAttempt,
|
||||
withPendingCheckoutAttemptId
|
||||
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
CheckoutAttributionMetadata,
|
||||
PaymentIntentSource
|
||||
} from '@/platform/telemetry/types'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
type CheckoutTier = TierKey | `${TierKey}-yearly`
|
||||
@@ -38,11 +31,6 @@ const getCheckoutAttributionForCloud =
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
interface PerformSubscriptionCheckoutOptions {
|
||||
openInNewTab?: boolean
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Core subscription checkout logic shared between PricingTable and
|
||||
* SubscriptionRedirectView. Handles:
|
||||
@@ -59,12 +47,10 @@ interface PerformSubscriptionCheckoutOptions {
|
||||
export async function performSubscriptionCheckout(
|
||||
tierKey: TierKey,
|
||||
currentBillingCycle: BillingCycle,
|
||||
options: PerformSubscriptionCheckoutOptions = {}
|
||||
openInNewTab: boolean = true
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
const { openInNewTab = true, paymentIntentSource } = options
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { userId } = storeToRefs(authStore)
|
||||
const telemetry = useTelemetry()
|
||||
@@ -122,29 +108,14 @@ export async function performSubscriptionCheckout(
|
||||
const data = await response.json()
|
||||
|
||||
if (data.checkout_url) {
|
||||
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: paymentIntentSource
|
||||
})
|
||||
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout(
|
||||
withPendingCheckoutAttemptId(
|
||||
{
|
||||
user_id: userId.value,
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new',
|
||||
...(paymentIntentSource
|
||||
? { payment_intent_source: paymentIntentSource }
|
||||
: {}),
|
||||
...checkoutAttribution
|
||||
},
|
||||
pendingAttempt
|
||||
)
|
||||
)
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new',
|
||||
...checkoutAttribution
|
||||
})
|
||||
}
|
||||
|
||||
if (openInNewTab) {
|
||||
@@ -152,9 +123,18 @@ export async function performSubscriptionCheckout(
|
||||
if (!checkoutWindow) {
|
||||
return
|
||||
}
|
||||
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
} else {
|
||||
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
globalThis.location.href = data.checkout_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
const { mockIsCloud, mockSubscribe, mockTrackBeginCheckout, mockUserId } =
|
||||
vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockSubscribe: vi.fn(),
|
||||
mockTrackBeginCheckout: vi.fn(),
|
||||
mockUserId: { value: 'user-1' as string | null }
|
||||
}))
|
||||
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockSubscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
@@ -20,12 +16,6 @@ vi.mock('@/config/comfyApi', () => ({
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { subscribe: mockSubscribe }
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackBeginCheckout: mockTrackBeginCheckout })
|
||||
}))
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
|
||||
}))
|
||||
|
||||
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
|
||||
|
||||
@@ -53,9 +43,7 @@ describe('performTeamSubscriptionCheckout', () => {
|
||||
billing_op_id: 'op_1'
|
||||
})
|
||||
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly', {
|
||||
paymentIntentSource: 'deep_link'
|
||||
})
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
|
||||
returnUrl: 'https://app.test/payment/success',
|
||||
@@ -63,14 +51,6 @@ describe('performTeamSubscriptionCheckout', () => {
|
||||
teamCreditStopId: 'team_700'
|
||||
})
|
||||
expect(assignedHref).toBe('https://stripe.test/pay')
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-1',
|
||||
tier: 'team',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
billing_op_id: 'op_1',
|
||||
payment_intent_source: 'deep_link'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
|
||||
@@ -102,16 +82,6 @@ describe('performTeamSubscriptionCheckout', () => {
|
||||
expect(assignedHref).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not track begin_checkout when subscribe fails', async () => {
|
||||
mockSubscribe.mockRejectedValueOnce(new Error('subscribe failed'))
|
||||
|
||||
await expect(
|
||||
performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
).rejects.toThrow('subscribe failed')
|
||||
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing off cloud', async () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
|
||||
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
interface PerformTeamSubscriptionCheckoutOptions {
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
|
||||
* link: subscribes to the per-credit Team plan at the chosen slider stop and
|
||||
@@ -28,8 +22,7 @@ interface PerformTeamSubscriptionCheckoutOptions {
|
||||
*/
|
||||
export async function performTeamSubscriptionCheckout(
|
||||
teamCreditStopId: string,
|
||||
billingCycle: BillingCycle,
|
||||
options: PerformTeamSubscriptionCheckoutOptions = {}
|
||||
billingCycle: BillingCycle
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
@@ -40,14 +33,6 @@ export async function performTeamSubscriptionCheckout(
|
||||
teamCreditStopId
|
||||
})
|
||||
|
||||
trackWorkspaceCheckoutStarted({
|
||||
tier: 'team',
|
||||
cycle: billingCycle,
|
||||
checkoutType: 'new',
|
||||
billingOpId: response.billing_op_id,
|
||||
paymentIntentSource: options.paymentIntentSource
|
||||
})
|
||||
|
||||
if (response.status === 'needs_payment_method') {
|
||||
// A needs_payment_method response without a URL is unusable: surface it to
|
||||
// the caller's error handling rather than silently dropping the user home
|
||||
|
||||
@@ -30,39 +30,6 @@ describe('TelemetryRegistry', () => {
|
||||
expect(b.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload)
|
||||
})
|
||||
|
||||
it('dispatches trackBeginCheckout with intent metadata to every provider', () => {
|
||||
const a: TelemetryProvider = { trackBeginCheckout: vi.fn() }
|
||||
const b: TelemetryProvider = {}
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(a)
|
||||
registry.registerProvider(b)
|
||||
|
||||
const metadata = {
|
||||
user_id: 'user-1',
|
||||
tier: 'pro' as const,
|
||||
cycle: 'monthly' as const,
|
||||
checkout_type: 'new' as const,
|
||||
payment_intent_source: 'subscribe_to_run' as const
|
||||
}
|
||||
registry.trackBeginCheckout(metadata)
|
||||
|
||||
expect(a.trackBeginCheckout).toHaveBeenCalledExactlyOnceWith(metadata)
|
||||
})
|
||||
|
||||
it('dispatches trackAddApiCreditButtonClicked with its source', () => {
|
||||
const provider: TelemetryProvider = {
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
}
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(provider)
|
||||
|
||||
registry.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
|
||||
|
||||
expect(
|
||||
provider.trackAddApiCreditButtonClicked
|
||||
).toHaveBeenCalledExactlyOnceWith({ source: 'credits_panel' })
|
||||
})
|
||||
|
||||
it('skips providers that do not implement trackSearchQuery', () => {
|
||||
const empty: TelemetryProvider = {}
|
||||
const registry = new TelemetryRegistry()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
@@ -100,10 +99,8 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackAddApiCreditButtonClicked?.(metadata)
|
||||
)
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.())
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
|
||||
@@ -313,42 +313,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('captures begin_checkout with intent metadata', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackBeginCheckout({
|
||||
user_id: 'user-1',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.BEGIN_CHECKOUT,
|
||||
{
|
||||
user_id: 'user-1',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('captures add-credit clicks with their source', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
|
||||
{ source: 'credits_panel' }
|
||||
)
|
||||
})
|
||||
|
||||
it('captures share attribution events', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -10,9 +10,7 @@ import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
@@ -352,12 +350,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(eventName, metadata)
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata)
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
|
||||
@@ -115,17 +115,6 @@ describe('HostTelemetrySink', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards add-credit clicks with their source', () => {
|
||||
new HostTelemetrySink().trackAddApiCreditButtonClicked({
|
||||
source: 'avatar_menu'
|
||||
})
|
||||
|
||||
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
|
||||
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
|
||||
{ source: 'avatar_menu' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when the host bridge is absent', () => {
|
||||
delete window.__comfyDesktop2
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
@@ -127,8 +126,8 @@ export class HostTelemetrySink implements TelemetryProvider {
|
||||
this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
|
||||
@@ -12,29 +12,12 @@
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*/
|
||||
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
|
||||
export type PaymentIntentSource =
|
||||
| 'subscription_required'
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
| 'deep_link'
|
||||
| 'subscribe_to_run'
|
||||
| 'subscribe_now_button'
|
||||
| 'upgrade_to_add_credits'
|
||||
| 'settings_billing_panel'
|
||||
| 'avatar_menu_plans'
|
||||
| 'team_members_panel'
|
||||
| 'invite_member_upsell'
|
||||
| 'upload_model_upgrade'
|
||||
| 'team_upgrade_resume'
|
||||
|
||||
export type SubscriptionCheckoutType = 'new' | 'change'
|
||||
export type SubscriptionCheckoutTier = TierKey | 'team'
|
||||
|
||||
/**
|
||||
* Authentication metadata for sign-up tracking
|
||||
*/
|
||||
@@ -443,23 +426,16 @@ export interface CheckoutAttributionMetadata {
|
||||
|
||||
export interface SubscriptionMetadata {
|
||||
current_tier?: string
|
||||
reason?: PaymentIntentSource
|
||||
}
|
||||
|
||||
export interface AddCreditsClickMetadata {
|
||||
source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel'
|
||||
reason?: SubscriptionDialogReason
|
||||
}
|
||||
|
||||
export interface BeginCheckoutMetadata
|
||||
extends Record<string, unknown>, CheckoutAttributionMetadata {
|
||||
user_id: string
|
||||
tier: SubscriptionCheckoutTier
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
checkout_attempt_id?: string
|
||||
billing_op_id?: string
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: TierKey
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
}
|
||||
|
||||
interface EcommerceItemMetadata {
|
||||
@@ -481,9 +457,8 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
|
||||
checkout_attempt_id: string
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: TierKey
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
value: number
|
||||
currency: string
|
||||
ecommerce: EcommerceMetadata
|
||||
@@ -514,7 +489,7 @@ export interface TelemetryProvider {
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void
|
||||
trackMonthlySubscriptionCancelled?(): void
|
||||
trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
trackApiCreditTopupSucceeded?(): void
|
||||
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void
|
||||
|
||||
@@ -258,6 +258,8 @@ export interface CurrentTeamCreditStop {
|
||||
stop_usd: number
|
||||
}
|
||||
|
||||
export type PaymentMethodCapability = 'none' | 'one_time_only' | 'reusable'
|
||||
|
||||
export interface BillingStatusResponse {
|
||||
is_active: boolean
|
||||
subscription_status?: BillingSubscriptionStatus
|
||||
@@ -269,6 +271,8 @@ export interface BillingStatusResponse {
|
||||
cancel_at?: string
|
||||
renewal_date?: string
|
||||
team_credit_stop?: CurrentTeamCreditStop
|
||||
payment_method_capability?: PaymentMethodCapability
|
||||
default_payment_method_type?: string
|
||||
}
|
||||
|
||||
export interface BillingBalanceResponse {
|
||||
@@ -285,13 +289,14 @@ interface CreateTopupRequest {
|
||||
idempotency_key?: string
|
||||
}
|
||||
|
||||
type TopupStatus = 'pending' | 'completed' | 'failed'
|
||||
type TopupStatus = 'pending' | 'completed' | 'failed' | 'needs_payment_method'
|
||||
|
||||
export interface CreateTopupResponse {
|
||||
billing_op_id: string
|
||||
topup_id: string
|
||||
status: TopupStatus
|
||||
amount_cents: number
|
||||
payment_method_url?: string
|
||||
}
|
||||
|
||||
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
|
||||
@@ -324,6 +329,15 @@ interface GetBillingEventsParams {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface AddPaymentMethodResponse {
|
||||
payment_method_url: string
|
||||
billing_op_id: string
|
||||
}
|
||||
|
||||
export interface SettleOwedBalanceResponse {
|
||||
billing_op_id: string
|
||||
}
|
||||
|
||||
class WorkspaceApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -790,5 +804,43 @@ export const workspaceApi = {
|
||||
} catch (err) {
|
||||
handleAxiosError(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initiate a Stripe SetupIntent to collect a payment method without charging.
|
||||
* POST /api/billing/add-payment-method
|
||||
*/
|
||||
async initiateAddPaymentMethod(): Promise<AddPaymentMethodResponse> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
try {
|
||||
const response = await workspaceApiClient.post<AddPaymentMethodResponse>(
|
||||
api.apiURL('/billing/add-payment-method'),
|
||||
null,
|
||||
{ headers }
|
||||
)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
handleAxiosError(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Settle the outstanding owed balance immediately.
|
||||
* POST /api/billing/settle-owed
|
||||
*/
|
||||
async settleOwedBalance(
|
||||
idempotencyKey?: string
|
||||
): Promise<SettleOwedBalanceResponse> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
try {
|
||||
const response = await workspaceApiClient.post<SettleOwedBalanceResponse>(
|
||||
api.apiURL('/billing/settle-owed'),
|
||||
idempotencyKey ? { idempotency_key: idempotencyKey } : null,
|
||||
{ headers }
|
||||
)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
handleAxiosError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ const handleOpenWorkspaceSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -336,12 +336,13 @@ const handleOpenPlanAndCreditsSettings = () => {
|
||||
}
|
||||
|
||||
const handleUpgradeToAddCredits = () => {
|
||||
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
192
src/platform/workspace/components/SpendLimitDialogContent.vue
Normal file
192
src/platform/workspace/components/SpendLimitDialogContent.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-8">
|
||||
<h2 class="m-0 text-lg font-bold text-base-foreground">
|
||||
<Skeleton v-if="ctaLoading" class="h-7 w-48" />
|
||||
<template v-else>{{ title }}</template>
|
||||
</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')"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Capability error fallback -->
|
||||
<p
|
||||
v-if="capabilityError"
|
||||
aria-live="polite"
|
||||
data-testid="capability-error-fallback"
|
||||
class="m-0 px-8 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('billing.spendLimit.capabilityError') }}
|
||||
</p>
|
||||
|
||||
<!-- one_time_only info box -->
|
||||
<div
|
||||
v-else-if="capability === 'one_time_only'"
|
||||
class="mx-8 flex items-start gap-2 rounded-lg bg-secondary-background p-3 text-sm"
|
||||
>
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
|
||||
/>
|
||||
<span class="text-base-foreground">{{
|
||||
$t('billing.spendLimit.oneTimeOnlyInfo', { method: methodLabel })
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col gap-4 p-8">
|
||||
<SubscriptionTermsNote
|
||||
v-if="!(capability === 'reusable' && scenario === 'payment_failed')"
|
||||
context="payment_method"
|
||||
/>
|
||||
|
||||
<Button
|
||||
:disabled="ctaLoading"
|
||||
:loading="ctaLoading"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="h-10 justify-center"
|
||||
:aria-label="ctaLabel"
|
||||
@click="handleMainCta"
|
||||
>
|
||||
<Skeleton v-if="ctaLoading" class="h-4 w-32" />
|
||||
<template v-else>{{ ctaLabel }}</template>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
class="cursor-pointer border-none bg-transparent text-sm text-muted-foreground transition-colors hover:text-base-foreground"
|
||||
@click="handleBuyManually"
|
||||
>
|
||||
{{ $t('billing.spendLimit.orBuyManually') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TODO: handle paused and dunning account states -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { PaymentMethodCapability } from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import SubscriptionTermsNote from '@/platform/workspace/components/SubscriptionTermsNote.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
type Scenario = 'limit_reached' | 'payment_failed'
|
||||
|
||||
const {
|
||||
scenario,
|
||||
capability,
|
||||
methodType,
|
||||
capabilityError = false
|
||||
} = defineProps<{
|
||||
scenario: Scenario
|
||||
capability: PaymentMethodCapability
|
||||
methodType?: string
|
||||
capabilityError?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const toastStore = useToastStore()
|
||||
const { manageSubscription } = useBillingContext()
|
||||
|
||||
const ctaLoading = ref(false)
|
||||
|
||||
const methodLabel = computed(() => {
|
||||
if (!methodType) return t('billing.spendLimit.defaultMethod')
|
||||
const knownMethods = ['alipay', 'card', 'us_bank_account', 'link']
|
||||
if (!knownMethods.includes(methodType))
|
||||
return t('billing.spendLimit.defaultMethod')
|
||||
return t(`billing.spendLimit.methodLabels.${methodType}`)
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
if (
|
||||
capabilityError ||
|
||||
capability === 'none' ||
|
||||
capability === 'one_time_only'
|
||||
) {
|
||||
return t('billing.spendLimit.addPaymentMethodTitle')
|
||||
}
|
||||
if (scenario === 'payment_failed') {
|
||||
return t('billing.spendLimit.paymentFailedTitle')
|
||||
}
|
||||
return t('billing.spendLimit.addPaymentMethodTitle')
|
||||
})
|
||||
|
||||
const ctaLabel = computed(() => {
|
||||
if (
|
||||
capabilityError ||
|
||||
capability === 'none' ||
|
||||
capability === 'one_time_only'
|
||||
) {
|
||||
return t('billing.spendLimit.addPaymentMethodCta')
|
||||
}
|
||||
if (scenario === 'payment_failed') {
|
||||
return t('billing.spendLimit.updatePaymentMethodCta')
|
||||
}
|
||||
return t('billing.spendLimit.addPaymentMethodCta')
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
dialogStore.closeDialog({ key: 'spend-limit' })
|
||||
}
|
||||
|
||||
async function handleMainCta() {
|
||||
if (ctaLoading.value) return
|
||||
ctaLoading.value = true
|
||||
try {
|
||||
if (capability === 'reusable' && scenario === 'payment_failed') {
|
||||
await manageSubscription()
|
||||
} else {
|
||||
const response = await workspaceApi.initiateAddPaymentMethod()
|
||||
const url = response.payment_method_url
|
||||
if (!new URL(url).hostname.endsWith('.stripe.com')) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.unknownError')
|
||||
})
|
||||
return
|
||||
}
|
||||
const paymentWindow = window.open(url, '_blank', 'noopener,noreferrer')
|
||||
if (!paymentWindow) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('subscription.preview.paymentPopupBlocked')
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
ctaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyManually() {
|
||||
handleClose()
|
||||
await dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
</script>
|
||||
@@ -38,17 +38,22 @@ function previewFixture(
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
n: (value: number) => value.toLocaleString('en-US')
|
||||
})
|
||||
}))
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
n: (value: number) => value.toLocaleString('en-US')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const globalOptions = {
|
||||
mocks: { $t: (key: string) => key },
|
||||
stubs: {
|
||||
'i18n-t': { template: '<span />' },
|
||||
SubscriptionTermsNote: { template: '<div />' },
|
||||
Button: {
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
|
||||
@@ -391,13 +391,12 @@ const showZeroState = computed(
|
||||
)
|
||||
|
||||
function handleSubscribeWorkspace() {
|
||||
showSubscriptionDialog({ reason: 'settings_billing_panel' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
|
||||
function handleUpgrade() {
|
||||
if (isFreeTierPlan.value)
|
||||
showPricingTable({ reason: 'settings_billing_panel' })
|
||||
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
|
||||
if (isFreeTierPlan.value) showPricingTable()
|
||||
else showSubscriptionDialog()
|
||||
}
|
||||
|
||||
function handleViewMoreDetails() {
|
||||
|
||||
@@ -113,7 +113,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
@@ -123,7 +123,7 @@ import UnifiedPricingTable from './UnifiedPricingTable.vue'
|
||||
|
||||
const { onClose, reason, initialPlanMode } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: PaymentIntentSource
|
||||
reason?: SubscriptionDialogReason
|
||||
initialPlanMode?: 'personal' | 'team'
|
||||
}>()
|
||||
|
||||
@@ -152,7 +152,7 @@ const {
|
||||
handleConfirmTransition,
|
||||
handleTeamSubscribe,
|
||||
handleResubscribe
|
||||
} = useSubscriptionCheckout(emit, reason)
|
||||
} = useSubscriptionCheckout(emit)
|
||||
|
||||
// Backspace mirrors the back arrow on the confirm step, but never while an
|
||||
// editable element is focused (let it delete text there).
|
||||
|
||||
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
|
||||
import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue'
|
||||
|
||||
@@ -17,10 +17,25 @@ const mockHandleResubscribe = vi.fn()
|
||||
const mockHandleSuccessClose = vi.fn()
|
||||
const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing')
|
||||
const mockPreviewData = ref<{ transition_type: string } | null>(null)
|
||||
const mockUseSubscriptionCheckout = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||
useSubscriptionCheckout: mockUseSubscriptionCheckout
|
||||
useSubscriptionCheckout: () => ({
|
||||
checkoutStep: mockCheckoutStep,
|
||||
isLoadingPreview: ref(false),
|
||||
loadingTier: ref(null),
|
||||
isSubscribing: ref(false),
|
||||
isResubscribing: ref(false),
|
||||
previewData: mockPreviewData,
|
||||
selectedTierKey: ref('standard'),
|
||||
selectedBillingCycle: ref('yearly'),
|
||||
isPolling: ref(false),
|
||||
handleSubscribeClick: mockHandleSubscribeClick,
|
||||
handleBackToPricing: mockHandleBackToPricing,
|
||||
handleAddCreditCard: mockHandleAddCreditCard,
|
||||
handleConfirmTransition: mockHandleConfirmTransition,
|
||||
handleResubscribe: mockHandleResubscribe,
|
||||
handleSuccessClose: mockHandleSuccessClose
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -76,7 +91,7 @@ const SuccessStub = {
|
||||
function renderComponent(
|
||||
props: {
|
||||
onClose?: () => void
|
||||
reason?: PaymentIntentSource
|
||||
reason?: SubscriptionDialogReason
|
||||
isPersonal?: boolean
|
||||
} = {}
|
||||
) {
|
||||
@@ -106,23 +121,6 @@ function renderComponent(
|
||||
describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSubscriptionCheckout.mockReturnValue({
|
||||
checkoutStep: mockCheckoutStep,
|
||||
isLoadingPreview: ref(false),
|
||||
loadingTier: ref(null),
|
||||
isSubscribing: ref(false),
|
||||
isResubscribing: ref(false),
|
||||
previewData: mockPreviewData,
|
||||
selectedTierKey: ref('standard'),
|
||||
selectedBillingCycle: ref('yearly'),
|
||||
isPolling: ref(false),
|
||||
handleSubscribeClick: mockHandleSubscribeClick,
|
||||
handleBackToPricing: mockHandleBackToPricing,
|
||||
handleAddCreditCard: mockHandleAddCreditCard,
|
||||
handleConfirmTransition: mockHandleConfirmTransition,
|
||||
handleResubscribe: mockHandleResubscribe,
|
||||
handleSuccessClose: mockHandleSuccessClose
|
||||
})
|
||||
mockCheckoutStep.value = 'pricing'
|
||||
mockPreviewData.value = null
|
||||
})
|
||||
@@ -134,15 +132,6 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes the reason into subscription checkout', () => {
|
||||
renderComponent({ reason: 'out_of_credits' })
|
||||
|
||||
expect(mockUseSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'out_of_credits'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the team workspace header by default', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('Team Workspace')).toBeInTheDocument()
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||
|
||||
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
||||
@@ -130,7 +130,7 @@ const {
|
||||
isPersonal = false
|
||||
} = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: PaymentIntentSource
|
||||
reason?: SubscriptionDialogReason
|
||||
isPersonal?: boolean
|
||||
}>()
|
||||
|
||||
@@ -154,7 +154,7 @@ const {
|
||||
handleConfirmTransition,
|
||||
handleResubscribe,
|
||||
handleSuccessClose
|
||||
} = useSubscriptionCheckout(emit, reason)
|
||||
} = useSubscriptionCheckout(emit)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,26 +1,54 @@
|
||||
<template>
|
||||
<p class="m-0 text-center text-xs text-muted-foreground">
|
||||
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
|
||||
<template #terms>
|
||||
<a
|
||||
href="https://www.comfy.org/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('subscription.preview.terms') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #privacy>
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('subscription.preview.privacyPolicy') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<template v-if="context === 'payment_method'">
|
||||
<i18n-t keypath="billing.consent.paymentMethodBody" tag="span">
|
||||
<template #settingsLink>
|
||||
<button
|
||||
class="cursor-pointer border-none bg-transparent p-0 text-xs text-muted-foreground underline transition-colors hover:text-base-foreground"
|
||||
@click="openSettings"
|
||||
>
|
||||
{{ $t('billing.consent.settingsLink') }}
|
||||
</button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
|
||||
<template #terms>
|
||||
<a
|
||||
href="https://www.comfy.org/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('subscription.preview.terms') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #privacy>
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('subscription.preview.privacyPolicy') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
|
||||
const { context = 'subscription' } = defineProps<{
|
||||
context?: 'subscription' | 'payment_method'
|
||||
}>()
|
||||
|
||||
const settingsDialog = useSettingsDialog()
|
||||
|
||||
function openSettings() {
|
||||
settingsDialog.show('workspace')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,12 +9,16 @@ import type {
|
||||
|
||||
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
n: (value: number) => value.toLocaleString('en-US')
|
||||
})
|
||||
}))
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
n: (value: number) => value.toLocaleString('en-US')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const globalOptions = {
|
||||
mocks: { $t: (key: string) => key },
|
||||
|
||||
@@ -61,9 +61,6 @@ function onDismiss() {
|
||||
|
||||
function onUpgrade() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
subscriptionDialog.show({
|
||||
planMode: 'team',
|
||||
reason: 'invite_member_upsell'
|
||||
})
|
||||
subscriptionDialog.show({ planMode: 'team' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -277,7 +277,7 @@ export function useMembersPanel() {
|
||||
}
|
||||
|
||||
function showTeamPlans() {
|
||||
subscriptionDialog.show({ planMode: 'team', reason: 'team_members_panel' })
|
||||
subscriptionDialog.show({ planMode: 'team' })
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { findPlanSlug } from './useSubscriptionCheckout'
|
||||
@@ -76,9 +75,7 @@ const {
|
||||
mockPlans,
|
||||
mockResubscribe,
|
||||
mockToastAdd,
|
||||
mockStartOperation,
|
||||
mockTrackBeginCheckout,
|
||||
mockUserId
|
||||
mockStartOperation
|
||||
} = vi.hoisted(() => ({
|
||||
mockSubscribe: vi.fn(),
|
||||
mockPreviewSubscribe: vi.fn(),
|
||||
@@ -87,9 +84,7 @@ const {
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockResubscribe: vi.fn(),
|
||||
mockToastAdd: vi.fn(),
|
||||
mockStartOperation: vi.fn(),
|
||||
mockTrackBeginCheckout: vi.fn(),
|
||||
mockUserId: { value: 'user-1' as string | null }
|
||||
mockStartOperation: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
@@ -124,14 +119,7 @@ vi.mock('primevue/usetoast', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackMonthlySubscriptionSucceeded: vi.fn(),
|
||||
trackBeginCheckout: mockTrackBeginCheckout
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
|
||||
useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
@@ -147,10 +135,10 @@ vi.mock('vue-i18n', async (importOriginal) => {
|
||||
describe('useSubscriptionCheckout', () => {
|
||||
let emit: ReturnType<typeof vi.fn>
|
||||
|
||||
async function setup(paymentIntentSource?: PaymentIntentSource) {
|
||||
async function setup() {
|
||||
const { useSubscriptionCheckout } =
|
||||
await import('./useSubscriptionCheckout')
|
||||
return useSubscriptionCheckout(emit as never, paymentIntentSource)
|
||||
return useSubscriptionCheckout(emit as never)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -158,7 +146,6 @@ describe('useSubscriptionCheckout', () => {
|
||||
vi.clearAllMocks()
|
||||
mockPlans.value = allPlans()
|
||||
mockStartOperation.mockResolvedValue({ status: 'succeeded' })
|
||||
mockUserId.value = 'user-1'
|
||||
emit = vi.fn()
|
||||
})
|
||||
|
||||
@@ -472,13 +459,6 @@ describe('useSubscriptionCheckout', () => {
|
||||
cancelUrl: 'https://platform.comfy.org/payment/failed'
|
||||
})
|
||||
expect(checkout.checkoutStep.value).toBe('success')
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tier: 'team',
|
||||
checkout_type: 'new',
|
||||
billing_op_id: 'op-team-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the annual plan slug for the yearly cycle', async () => {
|
||||
@@ -573,39 +553,6 @@ describe('useSubscriptionCheckout', () => {
|
||||
detail: 'Team payment failed'
|
||||
})
|
||||
)
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps team checkout_type as change when the preview request fails', async () => {
|
||||
const checkout = await setup()
|
||||
mockPreviewSubscribe.mockRejectedValueOnce(new Error('not supported'))
|
||||
await checkout.handleSubscribeTeamClick({
|
||||
stop: {
|
||||
id: 'team_1400',
|
||||
usd: 1400,
|
||||
credits: 295_400,
|
||||
discountedUsd: 1295
|
||||
},
|
||||
billingCycle: 'monthly',
|
||||
isChange: true
|
||||
})
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-team-change'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleTeamSubscribe()
|
||||
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tier: 'team',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
billing_op_id: 'op-team-change'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -656,47 +603,6 @@ describe('useSubscriptionCheckout', () => {
|
||||
expect(checkout.checkoutStep.value).toBe('success')
|
||||
})
|
||||
|
||||
it('skips begin_checkout when no user id is available', async () => {
|
||||
mockUserId.value = null
|
||||
const checkout = await setup('subscribe_to_run')
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-1'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
mockUserId.value = 'user-1'
|
||||
})
|
||||
|
||||
it('fires begin_checkout carrying the payment intent source', async () => {
|
||||
const checkout = await setup('subscribe_to_run')
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-1'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-1',
|
||||
tier: 'standard',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
billing_op_id: 'op-1',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
})
|
||||
|
||||
it('opens payment URL when needs_payment_method', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
@@ -814,7 +720,6 @@ describe('useSubscriptionCheckout', () => {
|
||||
detail: 'Payment failed'
|
||||
})
|
||||
)
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,26 +9,16 @@ import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
PaymentIntentSource,
|
||||
SubscriptionCheckoutType
|
||||
} from '@/platform/telemetry/types'
|
||||
import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview' | 'success'
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
|
||||
interface SelectedTeamCheckout {
|
||||
stop: TeamPlanSelection
|
||||
checkoutType: SubscriptionCheckoutType
|
||||
}
|
||||
|
||||
/**
|
||||
* Which screen the `preview` step shows. Only a change prorates: a team change
|
||||
* carries `previewData` (handleSubscribeTeamClick sets it solely for an immediate
|
||||
@@ -55,12 +45,9 @@ export function findPlanSlug(
|
||||
return plan?.slug ?? null
|
||||
}
|
||||
|
||||
export function useSubscriptionCheckout(
|
||||
emit: {
|
||||
(e: 'close', subscribed: boolean): void
|
||||
},
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
) {
|
||||
export function useSubscriptionCheckout(emit: {
|
||||
(e: 'close', subscribed: boolean): void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const {
|
||||
@@ -81,16 +68,13 @@ export function useSubscriptionCheckout(
|
||||
const isResubscribing = ref(false)
|
||||
const previewData = ref<PreviewSubscribeResponse | null>(null)
|
||||
const selectedTierKey = ref<CheckoutTierKey | null>(null)
|
||||
const selectedTeamCheckout = ref<SelectedTeamCheckout | null>(null)
|
||||
const selectedTeamStop = ref<TeamPlanSelection | null>(null)
|
||||
const selectedBillingCycle = ref<BillingCycle>('yearly')
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
const selectedTeamStop = computed(
|
||||
() => selectedTeamCheckout.value?.stop ?? null
|
||||
)
|
||||
const isTeamCheckout = computed(() => selectedTeamCheckout.value !== null)
|
||||
const isTeamCheckout = computed(() => selectedTeamStop.value !== null)
|
||||
|
||||
const previewVariant = computed<PreviewVariant>(() => {
|
||||
if (selectedTeamCheckout.value) {
|
||||
if (selectedTeamStop.value) {
|
||||
return previewData.value ? 'team-change' : 'team-new'
|
||||
}
|
||||
if (previewData.value) {
|
||||
@@ -170,10 +154,7 @@ export function useSubscriptionCheckout(
|
||||
billingCycle: BillingCycle
|
||||
isChange?: boolean
|
||||
}) {
|
||||
selectedTeamCheckout.value = {
|
||||
stop: payload.stop,
|
||||
checkoutType: payload.isChange ? 'change' : 'new'
|
||||
}
|
||||
selectedTeamStop.value = payload.stop
|
||||
selectedBillingCycle.value = payload.billingCycle
|
||||
selectedTierKey.value = null
|
||||
previewData.value = null
|
||||
@@ -201,7 +182,7 @@ export function useSubscriptionCheckout(
|
||||
function handleBackToPricing() {
|
||||
checkoutStep.value = 'pricing'
|
||||
previewData.value = null
|
||||
selectedTeamCheckout.value = null
|
||||
selectedTeamStop.value = null
|
||||
}
|
||||
|
||||
function handleSuccessClose() {
|
||||
@@ -209,34 +190,20 @@ export function useSubscriptionCheckout(
|
||||
}
|
||||
|
||||
async function handleSubscription() {
|
||||
const tierKey = selectedTierKey.value
|
||||
if (!tierKey) return
|
||||
|
||||
const billingCycle = selectedBillingCycle.value
|
||||
const checkoutType =
|
||||
previewData.value &&
|
||||
previewData.value.transition_type !== 'new_subscription'
|
||||
? 'change'
|
||||
: 'new'
|
||||
if (!selectedTierKey.value) return
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(tierKey, billingCycle)
|
||||
const planSlug = getApiPlanSlug(
|
||||
selectedTierKey.value,
|
||||
selectedBillingCycle.value
|
||||
)
|
||||
if (!planSlug) return
|
||||
const response = await subscribe(planSlug, {
|
||||
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
})
|
||||
|
||||
if (response) {
|
||||
trackWorkspaceCheckoutStarted({
|
||||
tier: tierKey,
|
||||
cycle: billingCycle,
|
||||
checkoutType,
|
||||
billingOpId: response.billing_op_id,
|
||||
paymentIntentSource
|
||||
})
|
||||
}
|
||||
await handleSubscribeResponse(response)
|
||||
} catch (error) {
|
||||
showSubscribeError(error)
|
||||
@@ -302,8 +269,8 @@ export function useSubscriptionCheckout(
|
||||
}
|
||||
|
||||
async function handleTeamSubscription() {
|
||||
const teamCheckout = selectedTeamCheckout.value
|
||||
if (!teamCheckout?.stop.id) {
|
||||
const stop = selectedTeamStop.value
|
||||
if (!stop?.id) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.teamPlan.name'),
|
||||
@@ -312,28 +279,16 @@ export function useSubscriptionCheckout(
|
||||
return
|
||||
}
|
||||
|
||||
const { stop, checkoutType } = teamCheckout
|
||||
const billingCycle = selectedBillingCycle.value
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getTeamPlanSlug(billingCycle)
|
||||
const planSlug = getTeamPlanSlug(selectedBillingCycle.value)
|
||||
const response = await subscribe(planSlug, {
|
||||
teamCreditStopId: stop.id,
|
||||
billingCycle,
|
||||
billingCycle: selectedBillingCycle.value,
|
||||
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
})
|
||||
|
||||
if (response) {
|
||||
trackWorkspaceCheckoutStarted({
|
||||
tier: 'team',
|
||||
cycle: billingCycle,
|
||||
checkoutType,
|
||||
billingOpId: response.billing_op_id,
|
||||
paymentIntentSource
|
||||
})
|
||||
}
|
||||
await handleSubscribeResponse(response)
|
||||
} catch (error) {
|
||||
showSubscribeError(error)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse,
|
||||
@@ -71,7 +70,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
effectiveBalanceMicros:
|
||||
data.effective_balance_micros ?? data.amount_micros,
|
||||
prepaidBalanceMicros: data.prepaid_balance_micros ?? 0,
|
||||
cloudCreditBalanceMicros: data.cloud_credit_balance_micros ?? 0
|
||||
cloudCreditBalanceMicros: data.cloud_credit_balance_micros ?? 0,
|
||||
pendingChargesMicros: data.pending_charges_micros
|
||||
}
|
||||
})
|
||||
|
||||
@@ -81,6 +81,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
)
|
||||
const tier = computed(() => statusData.value?.subscription_tier ?? null)
|
||||
const renewalDate = computed(() => statusData.value?.renewal_date ?? null)
|
||||
const paymentMethodCapability = computed(
|
||||
() => statusData.value?.payment_method_capability ?? null
|
||||
)
|
||||
const defaultPaymentMethodType = computed(
|
||||
() => statusData.value?.default_payment_method_type ?? null
|
||||
)
|
||||
|
||||
const plans = computed(() => billingPlans.plans.value)
|
||||
const currentPlanSlug = computed(
|
||||
@@ -276,12 +282,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
subscriptionDialog.show({ reason: 'subscription_required' })
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
|
||||
subscriptionDialog.show(options)
|
||||
function showSubscriptionDialog(): void {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -301,6 +307,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
paymentMethodCapability,
|
||||
defaultPaymentMethodType,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
|
||||
@@ -504,6 +504,161 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pay_owed operations', () => {
|
||||
it('shows immediate processing toast for pay_owed operations', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'pay_owed')
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'info',
|
||||
summary: 'billingOperation.payOwedProcessing',
|
||||
group: 'billing-operation'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not close any dialog or open settings on pay_owed success', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
const terminal = store.startOperation('op-1', 'pay_owed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await terminal
|
||||
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
expect(mockSettingsDialogShow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows pay_owed success toast on success', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
const terminal = store.startOperation('op-1', 'pay_owed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await terminal
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'billingOperation.payOwedSuccess',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('uses pay_owed failure message on failure', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'failed',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'pay_owed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'billingOperation.payOwedFailed',
|
||||
detail: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('uses pay_owed timeout message on timeout', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'pay_owed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(121_000)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'billingOperation.payOwedTimeout'
|
||||
})
|
||||
})
|
||||
|
||||
it('isPayingOwed is true while pay_owed operation is pending', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'pay_owed')
|
||||
|
||||
expect(store.isPayingOwed).toBe(true)
|
||||
})
|
||||
|
||||
it('isPayingOwed is false after pay_owed operation succeeds', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'pay_owed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(store.isPayingOwed).toBe(false)
|
||||
})
|
||||
|
||||
it('does not update workspace isSubscribed on pay_owed success', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
const terminal = store.startOperation('op-1', 'pay_owed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await terminal
|
||||
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes billing status and balance on pay_owed success', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
const terminal = store.startOperation('op-1', 'pay_owed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await terminal
|
||||
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
expect(mockFetchBalance).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exponential backoff', () => {
|
||||
it('uses exponential backoff for polling intervals', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
|
||||
@@ -16,7 +16,7 @@ const MAX_INTERVAL_MS = 8000
|
||||
const BACKOFF_MULTIPLIER = 1.5
|
||||
const TIMEOUT_MS = 120_000 // 2 minutes
|
||||
|
||||
type OperationType = 'subscription' | 'topup' | 'cancel'
|
||||
type OperationType = 'subscription' | 'topup' | 'cancel' | 'pay_owed'
|
||||
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
|
||||
|
||||
interface BillingOperation {
|
||||
@@ -53,6 +53,12 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
)
|
||||
)
|
||||
|
||||
const isPayingOwed = computed(() =>
|
||||
[...operations.value.values()].some(
|
||||
(op) => op.status === 'pending' && op.type === 'pay_owed'
|
||||
)
|
||||
)
|
||||
|
||||
function getOperation(opId: string) {
|
||||
return operations.value.get(opId)
|
||||
}
|
||||
@@ -81,7 +87,9 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
const messageKey =
|
||||
type === 'subscription'
|
||||
? 'billingOperation.subscriptionProcessing'
|
||||
: 'billingOperation.topupProcessing'
|
||||
: type === 'topup'
|
||||
? 'billingOperation.topupProcessing'
|
||||
: 'billingOperation.payOwedProcessing'
|
||||
|
||||
const toastMessage: ToastMessageOptions = {
|
||||
severity: 'info',
|
||||
@@ -169,6 +177,17 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// pay_owed: only refresh balance; do not close any dialog or open settings.
|
||||
if (operation.type === 'pay_owed') {
|
||||
useToastStore().add({
|
||||
severity: 'success',
|
||||
summary: t('billingOperation.payOwedSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
resolveTerminal(opId)
|
||||
return
|
||||
}
|
||||
|
||||
// A subscription checkout shows its own success step in the pricing dialog,
|
||||
// so leave it open. Top-ups have no such step: close and surface settings.
|
||||
if (operation.type === 'topup') {
|
||||
@@ -233,6 +252,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
function failureMessage(type: OperationType) {
|
||||
if (type === 'subscription') return t('billingOperation.subscriptionFailed')
|
||||
if (type === 'topup') return t('billingOperation.topupFailed')
|
||||
if (type === 'pay_owed') return t('billingOperation.payOwedFailed')
|
||||
return t('billingOperation.cancelFailed')
|
||||
}
|
||||
|
||||
@@ -240,6 +260,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
if (type === 'subscription')
|
||||
return t('billingOperation.subscriptionTimeout')
|
||||
if (type === 'topup') return t('billingOperation.topupTimeout')
|
||||
if (type === 'pay_owed') return t('billingOperation.payOwedTimeout')
|
||||
return t('billingOperation.cancelTimeout')
|
||||
}
|
||||
|
||||
@@ -295,6 +316,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
hasPendingOperations,
|
||||
isSettingUp,
|
||||
isAddingCredits,
|
||||
isPayingOwed,
|
||||
getOperation,
|
||||
startOperation,
|
||||
clearOperation
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
PaymentIntentSource,
|
||||
SubscriptionCheckoutTier,
|
||||
SubscriptionCheckoutType
|
||||
} from '@/platform/telemetry/types'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
interface TrackWorkspaceCheckoutStartedOptions {
|
||||
tier: SubscriptionCheckoutTier
|
||||
cycle: BillingCycle
|
||||
checkoutType: SubscriptionCheckoutType
|
||||
billingOpId: string
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
}
|
||||
|
||||
export function trackWorkspaceCheckoutStarted({
|
||||
tier,
|
||||
cycle,
|
||||
checkoutType,
|
||||
billingOpId,
|
||||
paymentIntentSource
|
||||
}: TrackWorkspaceCheckoutStartedOptions) {
|
||||
const { userId } = useAuthStore()
|
||||
if (!userId) return
|
||||
|
||||
useTelemetry()?.trackBeginCheckout({
|
||||
user_id: userId,
|
||||
tier,
|
||||
cycle,
|
||||
checkout_type: checkoutType,
|
||||
billing_op_id: billingOpId,
|
||||
...(paymentIntentSource
|
||||
? { payment_intent_source: paymentIntentSource }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
isActiveSubscription: true
|
||||
}))
|
||||
|
||||
const overlayMock = vi.hoisted(() => ({
|
||||
overlayMessage: 'KSampler is missing a required input: model',
|
||||
overlayTitle: 'Required input missing'
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billingMock.isActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/error/useErrorOverlayState', () => ({
|
||||
useErrorOverlayState: () => ({
|
||||
overlayMessage: overlayMock.overlayMessage,
|
||||
overlayTitle: overlayMock.overlayTitle
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
},
|
||||
mobileNoWorkflow: 'No workflow',
|
||||
runCount: 'Run count',
|
||||
viewJob: 'View job'
|
||||
},
|
||||
menu: {
|
||||
run: 'Run'
|
||||
},
|
||||
menuLabels: {
|
||||
publish: 'Publish'
|
||||
},
|
||||
queue: {
|
||||
jobAddedToQueue: 'Job added to queue',
|
||||
jobQueueing: 'Queueing'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const nodeErrors: Record<string, NodeError> = {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing input',
|
||||
details: '',
|
||||
extra_info: { input_name: 'prompt' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function renderControls({
|
||||
hasError = false,
|
||||
isActiveSubscription = true,
|
||||
mobile = false
|
||||
}: {
|
||||
hasError?: boolean
|
||||
isActiveSubscription?: boolean
|
||||
mobile?: boolean
|
||||
} = {}) {
|
||||
billingMock.isActiveSubscription = isActiveSubscription
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
})
|
||||
setActivePinia(pinia)
|
||||
|
||||
useAppModeStore().selectedOutputs = [toNodeId(1)]
|
||||
if (hasError) {
|
||||
useExecutionErrorStore().lastNodeErrors = nodeErrors
|
||||
}
|
||||
|
||||
const toastTarget = document.createElement('div')
|
||||
|
||||
return render(LinearControls, {
|
||||
props: { mobile, toastTo: toastTarget },
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
AppModeWidgetList: true,
|
||||
Loader: true,
|
||||
PartnerNodesList: true,
|
||||
Popover: {
|
||||
template: '<div><slot name="button" /><slot /></div>'
|
||||
},
|
||||
ScrubableNumberInput: true,
|
||||
SubscribeToRunButton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinearControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
billingMock.isActiveSubscription = true
|
||||
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
|
||||
overlayMock.overlayTitle = 'Required input missing'
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])('shows a workflow error warning in $label controls', ({ mobile }) => {
|
||||
renderControls({ hasError: true, mobile })
|
||||
|
||||
const warning = screen.getByRole('status')
|
||||
expect(
|
||||
within(warning).getByText('Required input missing')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(warning).getByText('KSampler is missing a required input: model')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(warning).getByRole('button', { name: 'Show errors in graph' })
|
||||
).toBeInTheDocument()
|
||||
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
const runButton = screen.getByRole('button', { name: 'Run' })
|
||||
expect(runButton).toHaveAttribute(
|
||||
'aria-describedby',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
const description = screen.getByTestId(
|
||||
'linear-validation-warning-description'
|
||||
)
|
||||
expect(description).toHaveAttribute(
|
||||
'id',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
expect(description).toHaveTextContent('Required input missing')
|
||||
expect(description).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(description).not.toHaveTextContent('Show errors in graph')
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'does not show the workflow error warning in $label controls without graph errors',
|
||||
({ mobile }) => {
|
||||
renderControls({ mobile })
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show errors in graph' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
|
||||
'aria-describedby'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'does not show the workflow error warning in $label controls without an active subscription',
|
||||
({ mobile }) => {
|
||||
renderControls({
|
||||
hasError: true,
|
||||
isActiveSubscription: false,
|
||||
mobile
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not show the warning when the error copy is empty', () => {
|
||||
overlayMock.overlayMessage = ''
|
||||
|
||||
renderControls({ hasError: true })
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
|
||||
'aria-describedby'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, toValue, useTemplateRef } from 'vue'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -15,15 +14,11 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
@@ -33,8 +28,6 @@ const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
|
||||
const { overlayMessage } = useErrorOverlayState()
|
||||
|
||||
const { toastTo, mobile } = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
@@ -50,13 +43,6 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
|
||||
{ controls: true, immediate: false }
|
||||
)
|
||||
const widgetListRef = useTemplateRef('widgetListRef')
|
||||
const linearRunButtonTestId = 'linear-run-button'
|
||||
const showRunErrorWarning = computed(
|
||||
() =>
|
||||
hasAnyError.value &&
|
||||
toValue(isActiveSubscription) &&
|
||||
toValue(overlayMessage).trim().length > 0
|
||||
)
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
@@ -148,10 +134,9 @@ function handleDragDrop() {
|
||||
<PartnerNodesList v-if="!mobile" />
|
||||
<section
|
||||
v-if="mobile"
|
||||
:data-testid="linearRunButtonTestId"
|
||||
data-testid="linear-run-button"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
@@ -181,24 +166,18 @@ function handleDragDrop() {
|
||||
variant="primary"
|
||||
class="grow"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else
|
||||
:data-testid="linearRunButtonTestId"
|
||||
data-testid="linear-run-button"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
@@ -219,14 +198,9 @@ function handleDragDrop() {
|
||||
variant="primary"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
overlayMessage: 'KSampler is missing a required input: model',
|
||||
overlayTitle: 'Required input missing',
|
||||
viewErrorsInGraph: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/error/useErrorOverlayState', () => ({
|
||||
useErrorOverlayState: () => ({
|
||||
overlayMessage: mocks.overlayMessage,
|
||||
overlayTitle: mocks.overlayTitle
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useViewErrorsInGraph', () => ({
|
||||
useViewErrorsInGraph: () => ({
|
||||
viewErrorsInGraph: mocks.viewErrorsInGraph
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderWarning() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(LinearRunErrorWarning, {
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('LinearRunErrorWarning', () => {
|
||||
beforeEach(() => {
|
||||
mocks.viewErrorsInGraph.mockReset()
|
||||
})
|
||||
|
||||
it('shows the current error overlay title and message without a close action', () => {
|
||||
renderWarning()
|
||||
|
||||
const warning = screen.getByRole('status')
|
||||
expect(warning).toHaveTextContent('Required input missing')
|
||||
expect(warning).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(screen.getByText('Required input missing')).toHaveAttribute(
|
||||
'title',
|
||||
'Required input missing'
|
||||
)
|
||||
const description = screen.getByTestId(
|
||||
'linear-validation-warning-description'
|
||||
)
|
||||
expect(description).toHaveAttribute(
|
||||
'id',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
expect(description).toHaveTextContent('Required input missing')
|
||||
expect(description).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(description).not.toHaveTextContent('Show errors in graph')
|
||||
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens graph errors when the action is clicked', async () => {
|
||||
const { user } = renderWarning()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Show errors in graph' })
|
||||
)
|
||||
|
||||
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
const { overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
data-testid="linear-validation-warning"
|
||||
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<div
|
||||
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
|
||||
data-testid="linear-validation-warning-description"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex w-full items-start gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
|
||||
:title="overlayTitle"
|
||||
>
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-full items-start gap-2"
|
||||
data-testid="linear-validation-warning-message"
|
||||
>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
<p
|
||||
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center justify-end pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="linear-view-errors"
|
||||
@click="viewErrorsInGraph"
|
||||
>
|
||||
{{ t('linearMode.error.goto') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,2 +0,0 @@
|
||||
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
|
||||
'linear-run-error-warning'
|
||||
@@ -18,8 +18,11 @@ import type {
|
||||
} from '@/stores/dialogStore'
|
||||
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PaymentMethodCapability,
|
||||
WorkspaceRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
// Lazy loaders for dialogs - components are loaded on first use
|
||||
const lazyApiNodesSignInContent = () =>
|
||||
@@ -442,9 +445,9 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function showSubscriptionRequiredDialog(
|
||||
options?: SubscriptionDialogOptions
|
||||
) {
|
||||
async function showSubscriptionRequiredDialog(options?: {
|
||||
reason?: SubscriptionDialogReason
|
||||
}) {
|
||||
if (!isCloud || !window.__CONFIG__?.subscription_required) {
|
||||
return
|
||||
}
|
||||
@@ -629,6 +632,25 @@ export const useDialogService = () => {
|
||||
* understand" confirm dialog when the workspace has no other members;
|
||||
* failures on that path surface as an error toast.
|
||||
*/
|
||||
async function showSpendLimitDialog(options: {
|
||||
scenario: 'limit_reached' | 'payment_failed'
|
||||
capability: PaymentMethodCapability
|
||||
methodType?: string
|
||||
capabilityError?: boolean
|
||||
}) {
|
||||
const { type } = useBillingContext()
|
||||
if (type.value !== 'workspace') return
|
||||
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/SpendLimitDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'spend-limit',
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
async function showDowngradeToPersonalDialog(options: {
|
||||
planName: string
|
||||
planSlug: string
|
||||
@@ -734,6 +756,7 @@ export const useDialogService = () => {
|
||||
showInviteMemberUpsellDialog,
|
||||
showBillingComingSoonDialog,
|
||||
showCancelSubscriptionDialog,
|
||||
showDowngradeToPersonalDialog
|
||||
showDowngradeToPersonalDialog,
|
||||
showSpendLimitDialog
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export function useBillingContext(): BillingContext {
|
||||
subscriptionStatus: computed(() => null),
|
||||
tier: computed(() => null),
|
||||
renewalDate: computed(() => null),
|
||||
paymentMethodCapability: computed(() => null),
|
||||
defaultPaymentMethodType: computed(() => null),
|
||||
getMaxSeats: (tierKey: string) => ({ creator: 5, pro: 20 })[tierKey] ?? 1,
|
||||
initialize: async () => {},
|
||||
fetchStatus: async () => {},
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computed, useTemplateRef } from 'vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
@@ -164,6 +165,7 @@ function dragDrop(e: DragEvent) {
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
|
||||
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
|
||||
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { createApp, h, nextTick, reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const {
|
||||
managerStore,
|
||||
nodePacksState,
|
||||
nodePacksError,
|
||||
nodePacksLoading,
|
||||
nodePacksReady,
|
||||
startFetch,
|
||||
cleanup,
|
||||
useNodePacks
|
||||
} = vi.hoisted(() => ({
|
||||
managerStore: {
|
||||
installedPacksIds: new Set<string>(),
|
||||
installedPacks: {},
|
||||
refreshInstalledList: vi.fn(),
|
||||
isPackInstalled: vi.fn()
|
||||
},
|
||||
nodePacksState: { value: [] as NodePack[] },
|
||||
nodePacksError: { value: undefined as unknown },
|
||||
nodePacksLoading: { value: false },
|
||||
nodePacksReady: { value: false },
|
||||
startFetch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
useNodePacks: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => managerStore
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/useNodePacks',
|
||||
() => ({
|
||||
useNodePacks
|
||||
})
|
||||
)
|
||||
|
||||
function mountInstalledPacks() {
|
||||
let result: ReturnType<typeof useInstalledPacks> | undefined
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = useInstalledPacks()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('useInstalledPacks did not initialize')
|
||||
return {
|
||||
result,
|
||||
unmount: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
function pack(overrides: Partial<NodePack> = {}): NodePack {
|
||||
return { id: 'pack-a', ...overrides } as NodePack
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
managerStore.installedPacksIds = reactive(new Set<string>())
|
||||
managerStore.installedPacks = reactive({})
|
||||
managerStore.refreshInstalledList.mockReset().mockResolvedValue(undefined)
|
||||
managerStore.isPackInstalled.mockReset().mockReturnValue(false)
|
||||
startFetch.mockReset().mockResolvedValue([])
|
||||
cleanup.mockReset()
|
||||
useNodePacks.mockReset().mockReturnValue({
|
||||
error: nodePacksError,
|
||||
isLoading: nodePacksLoading,
|
||||
isReady: nodePacksReady,
|
||||
nodePacks: nodePacksState,
|
||||
startFetch,
|
||||
cleanup
|
||||
})
|
||||
})
|
||||
|
||||
describe('useInstalledPacks', () => {
|
||||
it('refreshes an empty installed list before fetching packs', async () => {
|
||||
const { result, unmount } = mountInstalledPacks()
|
||||
|
||||
await result.startFetchInstalled()
|
||||
|
||||
expect(managerStore.refreshInstalledList).toHaveBeenCalledTimes(1)
|
||||
expect(startFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not refresh when installed pack ids are already present', async () => {
|
||||
managerStore.installedPacksIds.add('pack-a')
|
||||
const { result, unmount } = mountInstalledPacks()
|
||||
|
||||
await result.startFetchInstalled()
|
||||
|
||||
expect(managerStore.refreshInstalledList).not.toHaveBeenCalled()
|
||||
expect(startFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('prevents duplicate initialization fetches', async () => {
|
||||
let releaseRefresh: (() => void) | undefined
|
||||
managerStore.refreshInstalledList.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
)
|
||||
const { result, unmount } = mountInstalledPacks()
|
||||
|
||||
const firstFetch = result.startFetchInstalled()
|
||||
await result.startFetchInstalled()
|
||||
releaseRefresh?.()
|
||||
await firstFetch
|
||||
|
||||
expect(managerStore.refreshInstalledList).toHaveBeenCalledTimes(1)
|
||||
expect(startFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('fetches again when installed ids change', async () => {
|
||||
const { unmount } = mountInstalledPacks()
|
||||
|
||||
managerStore.installedPacksIds.add('pack-b')
|
||||
await nextTick()
|
||||
|
||||
expect(startFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('filters and exposes installed pack versions', () => {
|
||||
managerStore.isPackInstalled.mockImplementation((id?: string) => id === 'x')
|
||||
Object.assign(managerStore.installedPacks, {
|
||||
a: { cnr_id: 'x', ver: '1.0.0' },
|
||||
b: { aux_id: 'y' },
|
||||
c: { ver: 'missing-id' }
|
||||
})
|
||||
const { result, unmount } = mountInstalledPacks()
|
||||
|
||||
expect(
|
||||
result.filterInstalledPack([pack({ id: 'x' }), pack({ id: 'z' })])
|
||||
).toEqual([pack({ id: 'x' })])
|
||||
expect(result.installedPacksWithVersions.value).toEqual([
|
||||
{ id: 'x', version: '1.0.0' },
|
||||
{ id: 'y', version: '' }
|
||||
])
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('cleans up node pack fetching on unmount', () => {
|
||||
const { unmount } = mountInstalledPacks()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(cleanup).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,274 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
type CompatibilityCheck = {
|
||||
hasConflict: boolean
|
||||
conflicts: ConflictDetail[]
|
||||
}
|
||||
|
||||
const { managerStore, showDialog, checkNodeCompatibility } = vi.hoisted(() => ({
|
||||
managerStore: {
|
||||
installPack: { call: vi.fn(), clear: vi.fn() },
|
||||
isPackInstalling: vi.fn((_id?: string) => false),
|
||||
isPackInstalled: vi.fn((_id?: string) => false)
|
||||
},
|
||||
showDialog: vi.fn(),
|
||||
checkNodeCompatibility: vi.fn(
|
||||
(): CompatibilityCheck => ({ hasConflict: false, conflicts: [] })
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => managerStore
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useNodeConflictDialog',
|
||||
() => ({
|
||||
useNodeConflictDialog: () => ({ show: showDialog })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictDetection',
|
||||
() => ({
|
||||
useConflictDetection: () => ({ checkNodeCompatibility })
|
||||
})
|
||||
)
|
||||
|
||||
function pack(over: Partial<NodePack> = {}): NodePack {
|
||||
return { id: 'pack-a', name: 'Pack A', ...over } as NodePack
|
||||
}
|
||||
|
||||
function conflict(overrides: Partial<ConflictDetail> = {}): ConflictDetail {
|
||||
return {
|
||||
type: 'os',
|
||||
current_value: 'linux',
|
||||
required_value: 'darwin',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
managerStore.installPack.call.mockReset().mockResolvedValue(undefined)
|
||||
managerStore.installPack.clear.mockReset()
|
||||
managerStore.isPackInstalling.mockReset().mockReturnValue(false)
|
||||
managerStore.isPackInstalled.mockReset().mockReturnValue(false)
|
||||
showDialog.mockReset()
|
||||
checkNodeCompatibility.mockReset().mockReturnValue({
|
||||
hasConflict: false,
|
||||
conflicts: []
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePackInstall', () => {
|
||||
it('reports isInstalling when any pack is installing', () => {
|
||||
managerStore.isPackInstalling.mockImplementation(
|
||||
(id?: string) => id === 'pack-b'
|
||||
)
|
||||
const { isInstalling } = usePackInstall(() => [
|
||||
pack(),
|
||||
pack({ id: 'pack-b' })
|
||||
])
|
||||
expect(isInstalling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('reports not installing for an empty or idle pack list', () => {
|
||||
expect(usePackInstall(() => []).isInstalling.value).toBe(false)
|
||||
expect(
|
||||
usePackInstall(() => undefined as unknown as NodePack[]).isInstalling
|
||||
.value
|
||||
).toBe(false)
|
||||
expect(usePackInstall(() => [pack()]).isInstalling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('installs each pack and clears the command afterward', async () => {
|
||||
const { performInstallation } = usePackInstall(() => [])
|
||||
await performInstallation([
|
||||
pack({
|
||||
id: 'a',
|
||||
latest_version: { version: '1.2.0' }
|
||||
} as Partial<NodePack>),
|
||||
pack({ id: 'b', publisher: { name: 'Unclaimed' } } as Partial<NodePack>)
|
||||
])
|
||||
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledTimes(2)
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'a', selected_version: '1.2.0' })
|
||||
)
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'b', selected_version: 'nightly' })
|
||||
)
|
||||
expect(managerStore.installPack.clear).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installAllPacks installs only the not-yet-installed packs', async () => {
|
||||
managerStore.isPackInstalled.mockImplementation(
|
||||
(id?: string) => id === 'installed'
|
||||
)
|
||||
const { installAllPacks } = usePackInstall(() => [
|
||||
pack({ id: 'installed' }),
|
||||
pack({ id: 'fresh' })
|
||||
])
|
||||
|
||||
await installAllPacks()
|
||||
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledTimes(1)
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'fresh' })
|
||||
)
|
||||
})
|
||||
|
||||
it('installAllPacks returns early for empty or already installed packs', async () => {
|
||||
await usePackInstall(() => []).installAllPacks()
|
||||
|
||||
managerStore.isPackInstalled.mockReturnValue(true)
|
||||
await usePackInstall(() => [pack({ id: 'installed' })]).installAllPacks()
|
||||
|
||||
expect(managerStore.installPack.call).not.toHaveBeenCalled()
|
||||
expect(managerStore.installPack.clear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installAllPacks opens the conflict dialog instead of installing when conflicted', async () => {
|
||||
const osConflict = conflict()
|
||||
checkNodeCompatibility.mockReturnValue({
|
||||
hasConflict: true,
|
||||
conflicts: [osConflict]
|
||||
})
|
||||
const { installAllPacks } = usePackInstall(
|
||||
() => [pack({ id: 'x' })],
|
||||
() => true,
|
||||
() => [osConflict]
|
||||
)
|
||||
|
||||
await installAllPacks()
|
||||
|
||||
expect(showDialog).toHaveBeenCalledTimes(1)
|
||||
expect(showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conflictedPackages: [
|
||||
expect.objectContaining({
|
||||
package_id: 'x',
|
||||
package_name: 'Pack A',
|
||||
has_conflict: true,
|
||||
conflicts: [osConflict],
|
||||
is_compatible: false
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
expect(managerStore.installPack.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installAllPacks stops when conflict details are unavailable', async () => {
|
||||
const { installAllPacks } = usePackInstall(
|
||||
() => [pack({ id: 'x' })],
|
||||
() => true
|
||||
)
|
||||
|
||||
await installAllPacks()
|
||||
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
expect(managerStore.installPack.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('conflict dialog payload falls back for unnamed package data', async () => {
|
||||
checkNodeCompatibility.mockReturnValue({
|
||||
hasConflict: true,
|
||||
conflicts: [conflict()]
|
||||
})
|
||||
const { installAllPacks } = usePackInstall(
|
||||
() => [pack({ id: undefined, name: undefined })],
|
||||
() => true,
|
||||
() => [conflict()]
|
||||
)
|
||||
|
||||
await installAllPacks()
|
||||
|
||||
expect(showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conflictedPackages: [
|
||||
expect.objectContaining({
|
||||
package_id: '',
|
||||
package_name: ''
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('conflict dialog action installs only packs still missing', async () => {
|
||||
checkNodeCompatibility.mockReturnValue({
|
||||
hasConflict: false,
|
||||
conflicts: []
|
||||
})
|
||||
managerStore.isPackInstalled.mockImplementation(
|
||||
(id?: string) => id === 'installed'
|
||||
)
|
||||
const { installAllPacks } = usePackInstall(
|
||||
() => [pack({ id: 'installed' }), pack({ id: 'fresh' })],
|
||||
() => true,
|
||||
() => [conflict()]
|
||||
)
|
||||
|
||||
await installAllPacks()
|
||||
const [{ onButtonClick }] = showDialog.mock.calls[0]
|
||||
await onButtonClick()
|
||||
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledTimes(1)
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'fresh' })
|
||||
)
|
||||
expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('conflict dialog action returns when every pack is already installed', async () => {
|
||||
managerStore.isPackInstalled.mockReturnValue(true)
|
||||
const { installAllPacks } = usePackInstall(
|
||||
() => [pack({ id: 'installed' })],
|
||||
() => true,
|
||||
() => [conflict()]
|
||||
)
|
||||
|
||||
await installAllPacks()
|
||||
const [{ onButtonClick }] = showDialog.mock.calls[0]
|
||||
await onButtonClick()
|
||||
|
||||
expect(managerStore.installPack.call).not.toHaveBeenCalled()
|
||||
expect(managerStore.installPack.clear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the command when payload validation rejects', async () => {
|
||||
const { performInstallation } = usePackInstall(() => [])
|
||||
|
||||
await expect(
|
||||
performInstallation([pack({ id: undefined })])
|
||||
).rejects.toThrow('manager.packInstall.nodeIdRequired')
|
||||
|
||||
expect(managerStore.installPack.call).not.toHaveBeenCalled()
|
||||
expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves command cleanup in finally when one install fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
managerStore.installPack.call
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('failed'))
|
||||
const { performInstallation } = usePackInstall(() => [])
|
||||
|
||||
await performInstallation([pack({ id: 'a' }), pack({ id: 'b' })])
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[usePackInstall] Some installations failed:',
|
||||
[expect.any(Error)]
|
||||
)
|
||||
expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { managerStore } = vi.hoisted(() => ({
|
||||
managerStore: {
|
||||
isPackInstalled: vi.fn(),
|
||||
isPackEnabled: vi.fn(),
|
||||
getInstalledPackVersion: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => managerStore
|
||||
}))
|
||||
|
||||
function pack(overrides: Partial<NodePack> = {}): NodePack {
|
||||
return {
|
||||
id: 'pack-a',
|
||||
latest_version: { version: '1.2.0' },
|
||||
...overrides
|
||||
} as NodePack
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
managerStore.isPackInstalled.mockReset().mockReturnValue(true)
|
||||
managerStore.isPackEnabled.mockReset().mockReturnValue(true)
|
||||
managerStore.getInstalledPackVersion.mockReset().mockReturnValue('1.0.0')
|
||||
})
|
||||
|
||||
describe('usePackUpdateStatus', () => {
|
||||
it('detects semver updates for installed packs', () => {
|
||||
const status = usePackUpdateStatus(pack())
|
||||
|
||||
expect(status.installedVersion.value).toBe('1.0.0')
|
||||
expect(status.latestVersion.value).toBe('1.2.0')
|
||||
expect(status.isNightlyPack.value).toBe(false)
|
||||
expect(status.isUpdateAvailable.value).toBe(true)
|
||||
expect(status.canTryNightlyUpdate.value).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks update prompts when required version data is absent', () => {
|
||||
managerStore.isPackInstalled.mockReturnValue(false)
|
||||
expect(usePackUpdateStatus(pack()).isUpdateAvailable.value).toBe(false)
|
||||
|
||||
managerStore.isPackInstalled.mockReturnValue(true)
|
||||
managerStore.getInstalledPackVersion.mockReturnValue('')
|
||||
expect(usePackUpdateStatus(pack()).isUpdateAvailable.value).toBe(false)
|
||||
|
||||
managerStore.getInstalledPackVersion.mockReturnValue('1.0.0')
|
||||
expect(
|
||||
usePackUpdateStatus(pack({ latest_version: undefined })).isUpdateAvailable
|
||||
.value
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows enabled nightly packs to try update without semver comparison', () => {
|
||||
managerStore.getInstalledPackVersion.mockReturnValue('nightly')
|
||||
|
||||
const status = usePackUpdateStatus(pack())
|
||||
|
||||
expect(status.isNightlyPack.value).toBe(true)
|
||||
expect(status.isUpdateAvailable.value).toBe(false)
|
||||
expect(status.canTryNightlyUpdate.value).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks reactive pack sources', () => {
|
||||
const nodePack = ref(pack({ latest_version: { version: '1.0.0' } }))
|
||||
const status = usePackUpdateStatus(nodePack)
|
||||
|
||||
expect(status.isUpdateAvailable.value).toBe(false)
|
||||
|
||||
nodePack.value = pack({ latest_version: { version: '2.0.0' } })
|
||||
|
||||
expect(status.latestVersion.value).toBe('2.0.0')
|
||||
expect(status.isUpdateAvailable.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
@@ -23,8 +22,6 @@ type NodePack = components['schemas']['Node']
|
||||
describe('usePacksSelection', () => {
|
||||
let managerStore: ReturnType<typeof useComfyManagerStore>
|
||||
let mockIsPackInstalled: (packName: string | undefined) => boolean
|
||||
let mockGetInstalledPackVersion: (packName: string) => string | undefined
|
||||
let mockIsPackEnabled: (packName: string | undefined) => boolean
|
||||
|
||||
const createMockPack = (id: string): NodePack => ({
|
||||
id,
|
||||
@@ -47,12 +44,7 @@ describe('usePacksSelection', () => {
|
||||
|
||||
// Mock the isPackInstalled method
|
||||
mockIsPackInstalled = vi.fn()
|
||||
mockGetInstalledPackVersion = vi.fn()
|
||||
mockIsPackEnabled = vi.fn()
|
||||
managerStore.isPackInstalled = mockIsPackInstalled
|
||||
// Real store types this as returning string, but it can be undefined at runtime
|
||||
managerStore.getInstalledPackVersion = fromAny(mockGetInstalledPackVersion)
|
||||
managerStore.isPackEnabled = mockIsPackEnabled
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -383,35 +375,5 @@ describe('usePacksSelection', () => {
|
||||
expect(installedPacks.value).toHaveLength(2)
|
||||
expect(notInstalledPacks.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should only include enabled installed packs with non-semver versions as nightly', () => {
|
||||
const nodePacks = ref<NodePack[]>([
|
||||
{ ...createMockPack('missing-id'), id: undefined },
|
||||
createMockPack('no-version'),
|
||||
createMockPack('stable'),
|
||||
createMockPack('disabled-nightly'),
|
||||
createMockPack('enabled-nightly')
|
||||
])
|
||||
|
||||
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
|
||||
vi.mocked(mockGetInstalledPackVersion).mockImplementation((id) => {
|
||||
const versions: Record<string, string | undefined> = {
|
||||
stable: '1.2.3',
|
||||
'disabled-nightly': 'abc123',
|
||||
'enabled-nightly': 'def456'
|
||||
}
|
||||
return versions[id]
|
||||
})
|
||||
vi.mocked(mockIsPackEnabled).mockImplementation(
|
||||
(id) => id === 'enabled-nightly'
|
||||
)
|
||||
|
||||
const { nightlyPacks, hasNightlyPacks } = usePacksSelection(nodePacks)
|
||||
|
||||
expect(nightlyPacks.value.map((pack) => pack.id)).toEqual([
|
||||
'enabled-nightly'
|
||||
])
|
||||
expect(hasNightlyPacks.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import { createApp, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
|
||||
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
|
||||
type GraphNode = {
|
||||
type?: string
|
||||
properties?: {
|
||||
cnr_id?: unknown
|
||||
aux_id?: unknown
|
||||
ver?: unknown
|
||||
}
|
||||
}
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const {
|
||||
appState,
|
||||
nodeDefStore,
|
||||
registryStore,
|
||||
systemStatsStore,
|
||||
nodePacksState,
|
||||
nodePacksError,
|
||||
nodePacksLoading,
|
||||
nodePacksReady,
|
||||
startFetch,
|
||||
cleanup,
|
||||
useNodePacks
|
||||
} = vi.hoisted(() => ({
|
||||
appState: {
|
||||
rootGraph: undefined as undefined | { nodes: GraphNode[] }
|
||||
},
|
||||
nodeDefStore: {
|
||||
nodeDefsByName: {} as Record<string, { isCoreNode?: boolean }>
|
||||
},
|
||||
registryStore: {
|
||||
inferPackFromNodeName: { call: vi.fn() }
|
||||
},
|
||||
systemStatsStore: {
|
||||
systemStats: undefined as
|
||||
| undefined
|
||||
| { system?: { comfyui_version?: string } },
|
||||
refetchSystemStats: vi.fn()
|
||||
},
|
||||
nodePacksState: { value: [] as NodePack[] },
|
||||
nodePacksError: { value: undefined as unknown },
|
||||
nodePacksLoading: { value: false },
|
||||
nodePacksReady: { value: false },
|
||||
startFetch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
useNodePacks: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueUse>()
|
||||
return {
|
||||
...actual,
|
||||
createSharedComposable: <T extends (...args: never[]) => unknown>(fn: T) =>
|
||||
fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appState
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: () => registryStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => nodeDefStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: () => systemStatsStore
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
mapAllNodes: (
|
||||
graph: { nodes: GraphNode[] },
|
||||
mapper: (node: GraphNode) => unknown
|
||||
) => graph.nodes.map(mapper)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/useNodePacks',
|
||||
() => ({
|
||||
useNodePacks
|
||||
})
|
||||
)
|
||||
|
||||
function mountWorkflowPacks() {
|
||||
let result: ReturnType<typeof useWorkflowPacks> | undefined
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = useWorkflowPacks()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('useWorkflowPacks did not initialize')
|
||||
return {
|
||||
result,
|
||||
unmount: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
function node(overrides: GraphNode = {}): GraphNode {
|
||||
return {
|
||||
type: 'CustomNode',
|
||||
properties: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function pack(overrides: Partial<NodePack> = {}): NodePack {
|
||||
return { id: 'pack-a', ...overrides } as NodePack
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
appState.rootGraph = { nodes: [] }
|
||||
nodeDefStore.nodeDefsByName = {}
|
||||
registryStore.inferPackFromNodeName.call
|
||||
.mockReset()
|
||||
.mockResolvedValue(undefined)
|
||||
systemStatsStore.systemStats = undefined
|
||||
systemStatsStore.refetchSystemStats.mockReset().mockResolvedValue(undefined)
|
||||
startFetch.mockReset().mockResolvedValue([])
|
||||
cleanup.mockReset()
|
||||
useNodePacks.mockReset().mockReturnValue({
|
||||
error: nodePacksError,
|
||||
isLoading: nodePacksLoading,
|
||||
isReady: nodePacksReady,
|
||||
nodePacks: nodePacksState,
|
||||
startFetch,
|
||||
cleanup
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowPacks', () => {
|
||||
it('fetches explicit workflow packs and trims versions', async () => {
|
||||
appState.rootGraph = {
|
||||
nodes: [
|
||||
node({ properties: { cnr_id: 'pack-a', ver: ' v1.2.3\n' } }),
|
||||
node({ properties: { aux_id: 'pack-b' } }),
|
||||
node({ properties: { cnr_id: 'comfy-core' } })
|
||||
]
|
||||
}
|
||||
const { result, unmount } = mountWorkflowPacks()
|
||||
|
||||
await result.startFetchWorkflowPacks()
|
||||
|
||||
expect(useNodePacks).toHaveBeenCalled()
|
||||
const idsSource = useNodePacks.mock.calls[0][0]
|
||||
expect(idsSource.value).toEqual(['pack-a', 'pack-b'])
|
||||
expect(startFetch).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
result.filterWorkflowPack([
|
||||
pack({ id: 'pack-a' }),
|
||||
pack({ id: 'comfy-core' })
|
||||
])
|
||||
).toEqual([pack({ id: 'pack-a' })])
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('infers core node packs from system stats', async () => {
|
||||
nodeDefStore.nodeDefsByName = { KSampler: { isCoreNode: true } }
|
||||
systemStatsStore.systemStats = { system: { comfyui_version: '0.4.0' } }
|
||||
appState.rootGraph = { nodes: [node({ type: 'KSampler' })] }
|
||||
const { result, unmount } = mountWorkflowPacks()
|
||||
|
||||
await result.startFetchWorkflowPacks()
|
||||
|
||||
const idsSource = useNodePacks.mock.calls[0][0]
|
||||
expect(idsSource.value).toEqual(['comfy-core'])
|
||||
expect(systemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('refetches system stats and falls back to nightly for core nodes', async () => {
|
||||
nodeDefStore.nodeDefsByName = { KSampler: { isCoreNode: true } }
|
||||
appState.rootGraph = { nodes: [node({ type: 'KSampler' })] }
|
||||
const { result, unmount } = mountWorkflowPacks()
|
||||
|
||||
await result.startFetchWorkflowPacks()
|
||||
|
||||
const idsSource = useNodePacks.mock.calls[0][0]
|
||||
expect(idsSource.value).toEqual(['comfy-core'])
|
||||
expect(systemStatsStore.refetchSystemStats).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('infers registry packs and tracks unresolved nodes', async () => {
|
||||
registryStore.inferPackFromNodeName.call.mockImplementation(
|
||||
(name: string) =>
|
||||
name === 'KnownNode'
|
||||
? Promise.resolve({
|
||||
id: 'registry-pack',
|
||||
latest_version: { version: '3.0.0' }
|
||||
})
|
||||
: Promise.resolve(undefined)
|
||||
)
|
||||
appState.rootGraph = {
|
||||
nodes: [node({ type: 'KnownNode' }), node({ type: 'MissingNode' })]
|
||||
}
|
||||
const { result, unmount } = mountWorkflowPacks()
|
||||
|
||||
await result.startFetchWorkflowPacks()
|
||||
|
||||
const idsSource = useNodePacks.mock.calls[0][0]
|
||||
expect(idsSource.value).toEqual(['registry-pack'])
|
||||
expect(result.unresolvedNodeNames.value).toEqual(['MissingNode'])
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('resets workflow packs when no graph is ready and cleans up on unmount', async () => {
|
||||
appState.rootGraph = undefined
|
||||
const { result, unmount } = mountWorkflowPacks()
|
||||
|
||||
await result.startFetchWorkflowPacks()
|
||||
unmount()
|
||||
|
||||
const idsSource = useNodePacks.mock.calls[0][0]
|
||||
expect(idsSource.value).toEqual([])
|
||||
expect(cleanup).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,179 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
|
||||
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
const {
|
||||
listeners,
|
||||
managerStore,
|
||||
settingStore,
|
||||
commandStore,
|
||||
workflowService,
|
||||
managerService,
|
||||
runFullConflictAnalysis,
|
||||
useEventListener
|
||||
} = vi.hoisted(() => ({
|
||||
listeners: new Map<string, Listener>(),
|
||||
managerStore: {
|
||||
setStale: vi.fn()
|
||||
},
|
||||
settingStore: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn()
|
||||
},
|
||||
commandStore: {
|
||||
execute: vi.fn()
|
||||
},
|
||||
workflowService: {
|
||||
reloadCurrentWorkflow: vi.fn()
|
||||
},
|
||||
managerService: {
|
||||
rebootComfyUI: vi.fn()
|
||||
},
|
||||
runFullConflictAnalysis: vi.fn(),
|
||||
useEventListener: vi.fn(
|
||||
(_target: unknown, event: string, listener: Listener) => {
|
||||
listeners.set(event, listener)
|
||||
return vi.fn(() => listeners.delete(event))
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueUse>()
|
||||
return {
|
||||
...actual,
|
||||
createSharedComposable: <T extends (...args: never[]) => unknown>(fn: T) =>
|
||||
fn,
|
||||
useEventListener
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => settingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => workflowService
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => commandStore
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictDetection',
|
||||
() => ({
|
||||
useConflictDetection: () => ({ runFullConflictAnalysis })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
|
||||
useComfyManagerService: () => managerService
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => managerStore
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
listeners.clear()
|
||||
managerStore.setStale.mockReset()
|
||||
settingStore.get.mockReset().mockReturnValue(false)
|
||||
settingStore.set.mockReset().mockResolvedValue(undefined)
|
||||
commandStore.execute.mockReset().mockResolvedValue(undefined)
|
||||
workflowService.reloadCurrentWorkflow.mockReset().mockResolvedValue(undefined)
|
||||
managerService.rebootComfyUI.mockReset().mockResolvedValue(undefined)
|
||||
runFullConflictAnalysis.mockReset().mockResolvedValue(undefined)
|
||||
useEventListener.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useApplyChanges', () => {
|
||||
it('reboots, handles reconnect, refreshes state, and closes after completion', async () => {
|
||||
const onClose = vi.fn()
|
||||
const applyChanges = useApplyChanges()
|
||||
|
||||
const applying = applyChanges.applyChanges(onClose)
|
||||
listeners.get('reconnected')?.()
|
||||
await applying
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Toast.DisableReconnectingToast',
|
||||
true
|
||||
)
|
||||
expect(managerService.rebootComfyUI).toHaveBeenCalled()
|
||||
expect(managerStore.setStale).toHaveBeenCalled()
|
||||
expect(commandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.RefreshNodeDefinitions'
|
||||
)
|
||||
expect(workflowService.reloadCurrentWorkflow).toHaveBeenCalled()
|
||||
expect(runFullConflictAnalysis).toHaveBeenCalled()
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Toast.DisableReconnectingToast',
|
||||
false
|
||||
)
|
||||
expect(applyChanges.isRestarting.value).toBe(false)
|
||||
expect(applyChanges.isRestartCompleted.value).toBe(true)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores duplicate apply requests while restarting', async () => {
|
||||
managerService.rebootComfyUI.mockReturnValue(new Promise(() => {}))
|
||||
const applyChanges = useApplyChanges()
|
||||
|
||||
void applyChanges.applyChanges()
|
||||
await applyChanges.applyChanges()
|
||||
|
||||
expect(managerService.rebootComfyUI).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restores the toast setting and reports timeout when reconnect never arrives', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const applyChanges = useApplyChanges()
|
||||
|
||||
await applyChanges.applyChanges()
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Toast.DisableReconnectingToast',
|
||||
false
|
||||
)
|
||||
expect(applyChanges.isRestarting.value).toBe(false)
|
||||
expect(applyChanges.isRestartCompleted.value).toBe(false)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[useApplyChanges] Reconnect timed out'
|
||||
)
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('restores state and rethrows when reboot fails', async () => {
|
||||
const onClose = vi.fn()
|
||||
const error = new Error('reboot failed')
|
||||
managerService.rebootComfyUI.mockRejectedValue(error)
|
||||
const applyChanges = useApplyChanges()
|
||||
|
||||
await expect(applyChanges.applyChanges(onClose)).rejects.toThrow(error)
|
||||
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Toast.DisableReconnectingToast',
|
||||
false
|
||||
)
|
||||
expect(applyChanges.isRestarting.value).toBe(false)
|
||||
expect(applyChanges.isRestartCompleted.value).toBe(false)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -10,15 +10,10 @@ import { useInstalledPacks } from '@/workbench/extensions/manager/composables/no
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import type { ConflictAcknowledgmentState } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
|
||||
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
import {
|
||||
checkAcceleratorCompatibility,
|
||||
checkOSCompatibility
|
||||
} from '@/workbench/extensions/manager/utils/systemCompatibility'
|
||||
import { checkVersionCompatibility } from '@/workbench/extensions/manager/utils/versionUtil'
|
||||
|
||||
// Mock @vueuse/core until function
|
||||
@@ -223,14 +218,12 @@ describe('useConflictDetection', () => {
|
||||
typeof useSystemStatsStore
|
||||
>
|
||||
|
||||
const mockShouldShowConflictModal = ref(false)
|
||||
|
||||
const mockAcknowledgment = {
|
||||
checkComfyUIVersionChange: vi.fn(),
|
||||
acknowledgmentState: computed(
|
||||
() => ({}) as Partial<ConflictAcknowledgmentState>
|
||||
),
|
||||
shouldShowConflictModal: computed(() => mockShouldShowConflictModal.value),
|
||||
shouldShowConflictModal: computed(() => false),
|
||||
shouldShowRedDot: computed(() => false),
|
||||
shouldShowManagerBanner: computed(() => false),
|
||||
dismissRedDotNotification: vi.fn(),
|
||||
@@ -253,12 +246,6 @@ describe('useConflictDetection', () => {
|
||||
vi.mocked(useInstalledPacks).mockReturnValue(mockInstalledPacks)
|
||||
vi.mocked(useComfyManagerStore).mockReturnValue(mockManagerStore)
|
||||
vi.mocked(useConflictDetectionStore).mockReturnValue(mockConflictStore)
|
||||
vi.mocked(useManagerState).mockReturnValue({
|
||||
isNewManagerUI: ref(true)
|
||||
} as ReturnType<typeof useManagerState>)
|
||||
vi.mocked(checkVersionCompatibility).mockReturnValue(null)
|
||||
vi.mocked(checkOSCompatibility).mockReturnValue(null)
|
||||
vi.mocked(checkAcceleratorCompatibility).mockReturnValue(null)
|
||||
|
||||
// Reset mock implementations
|
||||
vi.mocked(mockInstalledPacks.startFetchInstalled).mockResolvedValue(
|
||||
@@ -276,7 +263,6 @@ describe('useConflictDetection', () => {
|
||||
mockInstalledPacksWithVersions.value = []
|
||||
// Reset conflicted packages
|
||||
mockConflictedPackages = []
|
||||
mockShouldShowConflictModal.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -310,21 +296,6 @@ describe('useConflictDetection', () => {
|
||||
accelerator: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back when collecting system environment throws', async () => {
|
||||
vi.mocked(useSystemStatsStore).mockImplementation(() => {
|
||||
throw new Error('stats unavailable')
|
||||
})
|
||||
|
||||
const { collectSystemEnvironment } = useConflictDetection()
|
||||
|
||||
await expect(collectSystemEnvironment()).resolves.toEqual({
|
||||
comfyui_version: undefined,
|
||||
frontend_version: undefined,
|
||||
os: undefined,
|
||||
accelerator: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('conflict detection', () => {
|
||||
@@ -475,240 +446,6 @@ describe('useConflictDetection', () => {
|
||||
required_value: 'Import error'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses fallbacks for pending packages and missing registry metadata', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{ id: 'pending-pack' } as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'missing-pack', version: '1.0.0' },
|
||||
{ id: 'pending-pack', version: '2.0.0' }
|
||||
]
|
||||
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
|
||||
node_versions: [
|
||||
{
|
||||
status: 'success' as const,
|
||||
identifier: { node_id: 'pending-pack', version: '2.0.0' },
|
||||
node_version: {
|
||||
status: 'NodeVersionStatusPending' as const,
|
||||
version: '2.0.0',
|
||||
publisher_id: 'publisher',
|
||||
node_id: 'pending-pack',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
supported_comfyui_version: undefined,
|
||||
supported_comfyui_frontend_version: undefined,
|
||||
supported_os: undefined,
|
||||
supported_accelerators: undefined
|
||||
} as components['schemas']['NodeVersion']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.results).toHaveLength(1)
|
||||
expect(result.results[0]).toMatchObject({
|
||||
package_id: 'pending-pack',
|
||||
has_conflict: true
|
||||
})
|
||||
expect(result.results[0].conflicts).toContainEqual({
|
||||
type: 'pending',
|
||||
current_value: 'installed',
|
||||
required_value: 'not_pending'
|
||||
})
|
||||
})
|
||||
|
||||
it('records compatibility conflicts from version, OS, and accelerator checks', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{
|
||||
id: 'compat-pack',
|
||||
name: 'Compatibility Pack'
|
||||
} as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'compat-pack', version: '1.0.0' }
|
||||
]
|
||||
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
|
||||
node_versions: [
|
||||
{
|
||||
status: 'success' as const,
|
||||
identifier: { node_id: 'compat-pack', version: '1.0.0' },
|
||||
node_version: {
|
||||
status: 'NodeVersionStatusActive' as const,
|
||||
version: '1.0.0',
|
||||
publisher_id: 'publisher',
|
||||
node_id: 'compat-pack',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
supported_comfyui_version: '>=9.0.0',
|
||||
supported_comfyui_frontend_version: '>=9.0.0',
|
||||
supported_os: ['Windows'],
|
||||
supported_accelerators: ['CUDA']
|
||||
} as components['schemas']['NodeVersion']
|
||||
}
|
||||
]
|
||||
})
|
||||
vi.mocked(checkVersionCompatibility).mockImplementation((type) => ({
|
||||
type,
|
||||
current_value: type,
|
||||
required_value: '>=9.0.0'
|
||||
}))
|
||||
vi.mocked(checkOSCompatibility).mockReturnValue({
|
||||
type: 'os',
|
||||
current_value: 'macOS',
|
||||
required_value: 'Windows'
|
||||
})
|
||||
vi.mocked(checkAcceleratorCompatibility).mockReturnValue({
|
||||
type: 'accelerator',
|
||||
current_value: 'Metal',
|
||||
required_value: 'CUDA'
|
||||
})
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.results[0].conflicts).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
type: 'comfyui_version',
|
||||
current_value: 'comfyui_version',
|
||||
required_value: '>=9.0.0'
|
||||
},
|
||||
{
|
||||
type: 'frontend_version',
|
||||
current_value: 'frontend_version',
|
||||
required_value: '>=9.0.0'
|
||||
},
|
||||
{ type: 'os', current_value: 'macOS', required_value: 'Windows' },
|
||||
{
|
||||
type: 'accelerator',
|
||||
current_value: 'Metal',
|
||||
required_value: 'CUDA'
|
||||
}
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('returns no results when installed packs are not ready', async () => {
|
||||
mockInstalledPacks.isReady.value = false
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{ id: 'not-ready' } as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'not-ready', version: '1.0.0' }
|
||||
]
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.results).toEqual([])
|
||||
expect(mockConflictStore.clearConflicts).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues when registry bulk lookup fails', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{ id: 'fallback-pack' } as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'fallback-pack', version: '1.0.0' }
|
||||
]
|
||||
vi.mocked(mockRegistryService.getBulkNodeVersions).mockRejectedValue(
|
||||
new Error('registry down')
|
||||
)
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.results).toEqual([
|
||||
{
|
||||
package_id: 'fallback-pack',
|
||||
package_name: 'fallback-pack',
|
||||
has_conflict: false,
|
||||
conflicts: [],
|
||||
is_compatible: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('continues when import failure lookup throws', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{ id: 'clean-pack' } as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'clean-pack', version: '1.0.0' }
|
||||
]
|
||||
vi.mocked(
|
||||
mockComfyManagerService.getImportFailInfoBulk
|
||||
).mockRejectedValue(new Error('manager down'))
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.results).toEqual([
|
||||
{
|
||||
package_id: 'clean-pack',
|
||||
package_name: 'clean-pack',
|
||||
has_conflict: false,
|
||||
conflicts: [],
|
||||
is_compatible: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('uses unknown import error text when failure details omit messages', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'unknown-fail-pack', version: '1.0.0' }
|
||||
]
|
||||
vi.mocked(
|
||||
mockComfyManagerService.getImportFailInfoBulk
|
||||
).mockResolvedValue({
|
||||
'unknown-fail-pack': {},
|
||||
'clean-pack': null
|
||||
})
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.results[0].conflicts).toContainEqual({
|
||||
type: 'import_failed',
|
||||
current_value: 'Unknown import error',
|
||||
required_value: 'Unknown import error'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an in-progress response when analysis is already running', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{ id: 'slow-pack' } as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'slow-pack', version: '1.0.0' }
|
||||
]
|
||||
let resolveFetch: () => void = () => {}
|
||||
vi.mocked(mockInstalledPacks.startFetchInstalled).mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveFetch = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const firstRun = runFullConflictAnalysis()
|
||||
const secondRun = await runFullConflictAnalysis()
|
||||
resolveFetch()
|
||||
await firstRun
|
||||
|
||||
expect(secondRun).toMatchObject({
|
||||
success: false,
|
||||
error_message: 'Already detecting conflicts'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
@@ -754,151 +491,5 @@ describe('useConflictDetection', () => {
|
||||
])
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('skips initialization when the new manager UI is disabled', async () => {
|
||||
vi.mocked(useManagerState).mockReturnValue({
|
||||
isNewManagerUI: ref(false)
|
||||
} as ReturnType<typeof useManagerState>)
|
||||
const { initializeConflictDetection } = useConflictDetection()
|
||||
|
||||
await initializeConflictDetection()
|
||||
|
||||
expect(mockInstalledPacks.startFetchInstalled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores initialization errors', async () => {
|
||||
vi.mocked(useSystemStatsStore).mockImplementation(() => {
|
||||
throw new Error('stats unavailable')
|
||||
})
|
||||
const { initializeConflictDetection } = useConflictDetection()
|
||||
|
||||
await expect(initializeConflictDetection()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal gating', () => {
|
||||
it('shows the modal after update when conflicts exist and acknowledgment allows it', async () => {
|
||||
mockConflictedPackages = [
|
||||
{
|
||||
package_id: 'conflict-pack',
|
||||
package_name: 'Conflict Pack',
|
||||
has_conflict: true,
|
||||
is_compatible: false,
|
||||
conflicts: []
|
||||
}
|
||||
]
|
||||
mockShouldShowConflictModal.value = true
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacksWithVersions.value = []
|
||||
const { shouldShowConflictModalAfterUpdate } = useConflictDetection()
|
||||
|
||||
await expect(shouldShowConflictModalAfterUpdate()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the modal hidden when acknowledgment blocks it', async () => {
|
||||
mockConflictedPackages = [
|
||||
{
|
||||
package_id: 'conflict-pack',
|
||||
package_name: 'Conflict Pack',
|
||||
has_conflict: true,
|
||||
is_compatible: false,
|
||||
conflicts: []
|
||||
}
|
||||
]
|
||||
mockShouldShowConflictModal.value = false
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacksWithVersions.value = []
|
||||
const { shouldShowConflictModalAfterUpdate } = useConflictDetection()
|
||||
|
||||
await expect(shouldShowConflictModalAfterUpdate()).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('node compatibility', () => {
|
||||
it('reports compatibility conflicts for registry nodes', async () => {
|
||||
const { collectSystemEnvironment, checkNodeCompatibility } =
|
||||
useConflictDetection()
|
||||
await collectSystemEnvironment()
|
||||
vi.mocked(checkOSCompatibility).mockReturnValue({
|
||||
type: 'os',
|
||||
current_value: 'macOS',
|
||||
required_value: 'Windows'
|
||||
})
|
||||
vi.mocked(checkAcceleratorCompatibility).mockReturnValue({
|
||||
type: 'accelerator',
|
||||
current_value: 'Metal',
|
||||
required_value: 'CUDA'
|
||||
})
|
||||
vi.mocked(checkVersionCompatibility).mockImplementation((type) => ({
|
||||
type,
|
||||
current_value: type,
|
||||
required_value: '>=9.0.0'
|
||||
}))
|
||||
|
||||
const result = checkNodeCompatibility({
|
||||
id: 'node-pack',
|
||||
status: 'NodeStatusBanned',
|
||||
supported_os: ['Windows'],
|
||||
supported_accelerators: ['CUDA'],
|
||||
supported_comfyui_version: '>=9.0.0',
|
||||
supported_comfyui_frontend_version: '>=9.0.0'
|
||||
} as components['schemas']['Node'])
|
||||
|
||||
expect(result).toEqual({
|
||||
hasConflict: true,
|
||||
conflicts: expect.arrayContaining([
|
||||
{ type: 'os', current_value: 'macOS', required_value: 'Windows' },
|
||||
{
|
||||
type: 'accelerator',
|
||||
current_value: 'Metal',
|
||||
required_value: 'CUDA'
|
||||
},
|
||||
{
|
||||
type: 'comfyui_version',
|
||||
current_value: 'comfyui_version',
|
||||
required_value: '>=9.0.0'
|
||||
},
|
||||
{
|
||||
type: 'frontend_version',
|
||||
current_value: 'frontend_version',
|
||||
required_value: '>=9.0.0'
|
||||
},
|
||||
{
|
||||
type: 'banned',
|
||||
current_value: 'installed',
|
||||
required_value: 'not_banned'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('reports pending version nodes as incompatible', () => {
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
|
||||
const result = checkNodeCompatibility({
|
||||
node_id: 'node-pack',
|
||||
version: '1.0.0',
|
||||
publisher_id: 'publisher',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
status: 'NodeVersionStatusPending'
|
||||
} as components['schemas']['NodeVersion'])
|
||||
|
||||
expect(result.conflicts).toContainEqual({
|
||||
type: 'pending',
|
||||
current_value: 'installed',
|
||||
required_value: 'not_pending'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports compatible registry nodes without conflicts', () => {
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
|
||||
expect(
|
||||
checkNodeCompatibility({
|
||||
id: 'node-pack',
|
||||
status: 'NodeStatusActive'
|
||||
} as components['schemas']['Node'])
|
||||
).toEqual({ hasConflict: false, conflicts: [] })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { useManagerDisplayPacks } from '@/workbench/extensions/manager/composables/useManagerDisplayPacks'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { state } = vi.hoisted(() => ({
|
||||
state: {
|
||||
installed: [] as NodePack[],
|
||||
workflow: [] as NodePack[],
|
||||
installedLoading: false,
|
||||
workflowLoading: false,
|
||||
installedReady: true,
|
||||
workflowReady: true,
|
||||
startFetchInstalled: vi.fn(),
|
||||
startFetchWorkflowPacks: vi.fn(),
|
||||
installedIds: new Set<string>(),
|
||||
installedVersions: {} as Record<string, string>,
|
||||
conflicts: [] as { package_id: string }[]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (orig) => ({
|
||||
...(await orig<typeof VueUse>()),
|
||||
whenever: (source: unknown, callback?: () => void) => {
|
||||
if (typeof source === 'function' && source() && callback) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/services/gateway/registrySearchGateway', () => ({
|
||||
useRegistrySearchGateway: () => ({
|
||||
getSortValue: (pack: NodePack, field: string) =>
|
||||
(pack as Record<string, unknown>)[field],
|
||||
getSortableFields: () => [{ id: 'name', direction: 'asc' }]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/useInstalledPacks',
|
||||
() => ({
|
||||
useInstalledPacks: () => ({
|
||||
startFetchInstalled: state.startFetchInstalled,
|
||||
filterInstalledPack: (packs: NodePack[]) =>
|
||||
packs.filter((p) => state.installedIds.has(p.id ?? '')),
|
||||
installedPacks: ref(state.installed),
|
||||
isLoading: ref(state.installedLoading),
|
||||
isReady: ref(state.installedReady)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks',
|
||||
() => ({
|
||||
useWorkflowPacks: () => ({
|
||||
startFetchWorkflowPacks: state.startFetchWorkflowPacks,
|
||||
filterWorkflowPack: (packs: NodePack[]) => packs,
|
||||
workflowPacks: ref(state.workflow),
|
||||
isLoading: ref(state.workflowLoading),
|
||||
isReady: ref(state.workflowReady)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => ({
|
||||
isPackInstalled: (id: string | undefined) =>
|
||||
state.installedIds.has(id ?? ''),
|
||||
getInstalledPackVersion: (id: string) => state.installedVersions[id]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/conflictDetectionStore', () => ({
|
||||
useConflictDetectionStore: () => ({
|
||||
get conflictedPackages() {
|
||||
return state.conflicts
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function pack(id: string, latestVersion?: string): NodePack {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
latest_version: latestVersion ? { version: latestVersion } : undefined
|
||||
} as NodePack
|
||||
}
|
||||
|
||||
function display(
|
||||
tab: ManagerTab,
|
||||
searchResults: NodePack[] = [],
|
||||
query = '',
|
||||
sortField = ''
|
||||
) {
|
||||
return useManagerDisplayPacks(
|
||||
ref(tab),
|
||||
ref(searchResults),
|
||||
ref(query),
|
||||
ref(sortField)
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.installed = []
|
||||
state.workflow = []
|
||||
state.installedLoading = false
|
||||
state.workflowLoading = false
|
||||
state.installedReady = true
|
||||
state.workflowReady = true
|
||||
state.startFetchInstalled.mockReset()
|
||||
state.startFetchWorkflowPacks.mockReset()
|
||||
state.installedIds = new Set()
|
||||
state.installedVersions = {}
|
||||
state.conflicts = []
|
||||
})
|
||||
|
||||
describe('useManagerDisplayPacks', () => {
|
||||
it('All tab returns the raw search results', () => {
|
||||
const results = [pack('a'), pack('b')]
|
||||
expect(display(ManagerTab.All, results).displayPacks.value).toEqual(results)
|
||||
})
|
||||
|
||||
it('NotInstalled tab excludes installed packs', () => {
|
||||
state.installedIds = new Set(['a'])
|
||||
const { displayPacks } = display(ManagerTab.NotInstalled, [
|
||||
pack('a'),
|
||||
pack('b')
|
||||
])
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('AllInstalled tab shows installed packs when not searching', () => {
|
||||
state.installed = [pack('x'), pack('y')]
|
||||
const { displayPacks } = display(ManagerTab.AllInstalled)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['x', 'y'])
|
||||
})
|
||||
|
||||
it('UpdateAvailable tab keeps only installed packs with a newer latest version', () => {
|
||||
state.installedIds = new Set(['old', 'current', 'nightly'])
|
||||
state.installedVersions = {
|
||||
old: '1.0.0',
|
||||
current: '2.0.0',
|
||||
nightly: 'not-semver'
|
||||
}
|
||||
state.installed = [
|
||||
pack('old', '1.2.0'),
|
||||
pack('current', '2.0.0'),
|
||||
pack('nightly', '9.9.9'),
|
||||
pack('uninstalled', '5.0.0')
|
||||
]
|
||||
const { displayPacks } = display(ManagerTab.UpdateAvailable)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['old'])
|
||||
})
|
||||
|
||||
it('Conflicting tab keeps packs flagged by the conflict store', () => {
|
||||
state.installed = [pack('a'), pack('b')]
|
||||
state.conflicts = [{ package_id: 'b' }]
|
||||
const { displayPacks } = display(ManagerTab.Conflicting)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('Missing tab returns workflow packs that are not installed', () => {
|
||||
state.workflow = [pack('a'), pack('b')]
|
||||
state.installedIds = new Set(['a'])
|
||||
const { displayPacks, missingNodePacks } = display(ManagerTab.Missing)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
expect(missingNodePacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('Unresolved tab is always empty', () => {
|
||||
expect(
|
||||
display(ManagerTab.Unresolved, [pack('a')]).displayPacks.value
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('reports loading state scoped to the active tab group', () => {
|
||||
state.installedLoading = true
|
||||
state.workflowLoading = false
|
||||
expect(display(ManagerTab.AllInstalled).isLoading.value).toBe(true)
|
||||
expect(display(ManagerTab.All).isLoading.value).toBe(false)
|
||||
|
||||
state.installedLoading = false
|
||||
state.workflowLoading = true
|
||||
expect(display(ManagerTab.Workflow).isLoading.value).toBe(true)
|
||||
expect(display(ManagerTab.Missing).isLoading.value).toBe(true)
|
||||
})
|
||||
|
||||
it('fetches installed packs when an installed tab is selected and not ready', () => {
|
||||
state.installedReady = false
|
||||
display(ManagerTab.AllInstalled)
|
||||
|
||||
expect(state.startFetchInstalled).toHaveBeenCalledTimes(1)
|
||||
expect(state.startFetchWorkflowPacks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches workflow and installed packs for missing workflow dependencies', () => {
|
||||
state.installedReady = false
|
||||
state.workflowReady = false
|
||||
display(ManagerTab.Missing)
|
||||
|
||||
expect(state.startFetchInstalled).toHaveBeenCalledTimes(1)
|
||||
expect(state.startFetchWorkflowPacks).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('filters search results to installed packs on the AllInstalled tab while searching', () => {
|
||||
state.installedIds = new Set(['a'])
|
||||
const { displayPacks } = display(
|
||||
ManagerTab.AllInstalled,
|
||||
[pack('a'), pack('b')],
|
||||
'query'
|
||||
)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('filters searched update and conflict tabs before applying tab rules', () => {
|
||||
state.installedIds = new Set(['old', 'conflict'])
|
||||
state.installedVersions = {
|
||||
old: '1.0.0',
|
||||
conflict: '1.0.0'
|
||||
}
|
||||
state.conflicts = [{ package_id: 'conflict' }]
|
||||
const results = [
|
||||
pack('old', '2.0.0'),
|
||||
pack('current', '1.0.0'),
|
||||
pack('conflict', '1.0.0')
|
||||
]
|
||||
|
||||
expect(
|
||||
display(
|
||||
ManagerTab.UpdateAvailable,
|
||||
results,
|
||||
'query'
|
||||
).displayPacks.value.map((p) => p.id)
|
||||
).toEqual(['old'])
|
||||
expect(
|
||||
display(ManagerTab.Conflicting, results, 'query').displayPacks.value.map(
|
||||
(p) => p.id
|
||||
)
|
||||
).toEqual(['conflict'])
|
||||
})
|
||||
|
||||
it('filters workflow search results on the Workflow tab while searching', () => {
|
||||
const { displayPacks } = display(
|
||||
ManagerTab.Workflow,
|
||||
[pack('a'), pack('b')],
|
||||
'query'
|
||||
)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('filters searched missing workflow packs to not-installed packs', () => {
|
||||
state.installedIds = new Set(['a'])
|
||||
const { displayPacks } = display(
|
||||
ManagerTab.Missing,
|
||||
[pack('a'), pack('b')],
|
||||
'query'
|
||||
)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('falls back to search results for unknown tabs', () => {
|
||||
const results = [pack('a')]
|
||||
expect(
|
||||
display('unknown' as ManagerTab, results).displayPacks.value
|
||||
).toEqual(results)
|
||||
})
|
||||
|
||||
it('sorts installed packs by the configured field', () => {
|
||||
state.installed = [pack('b'), pack('a'), pack('c')]
|
||||
const { displayPacks } = display(ManagerTab.AllInstalled, [], '', 'name')
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
@@ -5,23 +5,12 @@ import { ref } from 'vue'
|
||||
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
|
||||
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||
|
||||
const mockAppApi = vi.hoisted(() => ({
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
mockAppApi.listeners.set(type, listener)
|
||||
}),
|
||||
listeners: new Map<string, EventListener>(),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (mockAppApi.listeners.get(type) === listener) {
|
||||
mockAppApi.listeners.delete(type)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock the app API
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
api: {
|
||||
addEventListener: mockAppApi.addEventListener,
|
||||
removeEventListener: mockAppApi.removeEventListener,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
clientId: 'test-client-id'
|
||||
}
|
||||
}
|
||||
@@ -32,43 +21,6 @@ type ManagerTaskHistory = Record<
|
||||
components['schemas']['TaskHistoryItem']
|
||||
>
|
||||
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
|
||||
type QueueTaskItem = components['schemas']['QueueTaskItem']
|
||||
|
||||
function createQueueTask(
|
||||
uiId: string,
|
||||
clientId = 'test-client-id'
|
||||
): QueueTaskItem {
|
||||
return {
|
||||
ui_id: uiId,
|
||||
client_id: clientId,
|
||||
kind: 'install',
|
||||
params: {
|
||||
id: uiId,
|
||||
version: '1.0.0',
|
||||
selected_version: '1.0.0',
|
||||
mode: 'remote',
|
||||
channel: 'default'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskState(
|
||||
overrides: Partial<ManagerTaskQueue> = {}
|
||||
): ManagerTaskQueue {
|
||||
return {
|
||||
history: {},
|
||||
running_queue: [],
|
||||
pending_queue: [],
|
||||
installed_packs: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchManagerEvent(type: string, detail: unknown) {
|
||||
const listener = mockAppApi.listeners.get(type)
|
||||
if (!listener) throw new Error(`Missing listener for ${type}`)
|
||||
listener(new CustomEvent(type, { detail }))
|
||||
}
|
||||
|
||||
describe('useManagerQueue', () => {
|
||||
let taskHistory: Ref<ManagerTaskHistory>
|
||||
@@ -92,12 +44,10 @@ describe('useManagerQueue', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppApi.listeners.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppApi.listeners.clear()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
@@ -284,82 +234,5 @@ describe('useManagerQueue', () => {
|
||||
// installedPacks should remain unchanged
|
||||
expect(installedPacks.value).toEqual({})
|
||||
})
|
||||
|
||||
it('updates task state from task started websocket events', () => {
|
||||
const queue = createManagerQueue()
|
||||
|
||||
dispatchManagerEvent('cm-task-started', {
|
||||
state: createTaskState({
|
||||
running_queue: [createQueueTask('running-task')],
|
||||
pending_queue: [createQueueTask('other-client-task', 'other-client')]
|
||||
})
|
||||
})
|
||||
|
||||
expect(taskQueue.value.running_queue).toEqual([
|
||||
createQueueTask('running-task')
|
||||
])
|
||||
expect(taskQueue.value.pending_queue).toEqual([])
|
||||
expect(queue.currentQueueLength.value).toBe(1)
|
||||
expect(queue.isProcessing.value).toBe(true)
|
||||
expect(queue.allTasksDone.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates task state from task completed websocket events', () => {
|
||||
const queue = createManagerQueue()
|
||||
|
||||
dispatchManagerEvent('cm-task-completed', {
|
||||
state: createTaskState({
|
||||
history: {
|
||||
completed: {
|
||||
ui_id: 'completed',
|
||||
client_id: 'test-client-id',
|
||||
kind: 'install',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
result: 'success'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(queue.historyCount.value).toBe(1)
|
||||
expect(taskHistory.value).toHaveProperty('completed')
|
||||
})
|
||||
|
||||
it('ignores malformed websocket events', () => {
|
||||
const queue = createManagerQueue()
|
||||
|
||||
dispatchManagerEvent('cm-task-started', {
|
||||
state: undefined
|
||||
})
|
||||
dispatchManagerEvent('cm-task-completed', {})
|
||||
|
||||
expect(queue.currentQueueLength.value).toBe(0)
|
||||
expect(queue.historyCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('removes websocket listeners and resets local flags on cleanup', () => {
|
||||
const queue = createManagerQueue()
|
||||
queue.isLoading.value = true
|
||||
queue.updateTaskState(
|
||||
createTaskState({
|
||||
running_queue: [createQueueTask('running-task')]
|
||||
})
|
||||
)
|
||||
|
||||
queue.cleanup()
|
||||
|
||||
expect(queue.isLoading.value).toBe(false)
|
||||
expect(queue.isProcessing.value).toBe(false)
|
||||
expect(mockAppApi.removeEventListener).toHaveBeenCalledWith(
|
||||
'cm-task-completed',
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
expect(mockAppApi.removeEventListener).toHaveBeenCalledWith(
|
||||
'cm-task-started',
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
__resetIncompatibleToastGuard,
|
||||
useManagerState
|
||||
} from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
// Mock dependencies that are not stores
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
@@ -32,38 +31,24 @@ vi.mock('@/composables/useFeatureFlags', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
commandExecuteMock,
|
||||
managerDialogHideMock,
|
||||
managerDialogShowMock,
|
||||
settingsHideMock,
|
||||
settingsShowAboutMock,
|
||||
settingsShowMock,
|
||||
toastAddMock
|
||||
} = vi.hoisted(() => ({
|
||||
commandExecuteMock: vi.fn(),
|
||||
managerDialogHideMock: vi.fn(),
|
||||
managerDialogShowMock: vi.fn(),
|
||||
settingsHideMock: vi.fn(),
|
||||
settingsShowAboutMock: vi.fn(),
|
||||
settingsShowMock: vi.fn(),
|
||||
toastAddMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: settingsShowMock,
|
||||
hide: settingsHideMock,
|
||||
showAbout: settingsShowAboutMock
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
showAbout: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: vi.fn(() => ({
|
||||
execute: commandExecuteMock
|
||||
execute: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
const { toastAddMock } = vi.hoisted(() => ({
|
||||
toastAddMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({
|
||||
add: toastAddMock
|
||||
@@ -71,10 +56,12 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerDialog', () => {
|
||||
const show = vi.fn()
|
||||
const hide = vi.fn()
|
||||
return {
|
||||
useManagerDialog: vi.fn(() => ({
|
||||
show: managerDialogShowMock,
|
||||
hide: managerDialogHideMock
|
||||
show,
|
||||
hide
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -97,17 +84,6 @@ const systemStatsFixture = (argv: string[]) => ({
|
||||
devices: []
|
||||
})
|
||||
|
||||
const enabledManagerStats = () =>
|
||||
systemStatsFixture(['python', 'main.py', '--enable-manager'])
|
||||
|
||||
const legacyManagerStats = () =>
|
||||
systemStatsFixture([
|
||||
'python',
|
||||
'main.py',
|
||||
'--enable-manager',
|
||||
'--enable-manager-legacy-ui'
|
||||
])
|
||||
|
||||
/**
|
||||
* Mocks the two server feature flags queried by useManagerState.
|
||||
* `supports_v4` → `extension.manager.supports_v4`
|
||||
@@ -162,7 +138,12 @@ describe('useManagerState', () => {
|
||||
|
||||
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: legacyManagerStats(),
|
||||
systemStats: systemStatsFixture([
|
||||
'python',
|
||||
'main.py',
|
||||
'--enable-manager',
|
||||
'--enable-manager-legacy-ui'
|
||||
]),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
@@ -256,28 +237,12 @@ describe('useManagerState', () => {
|
||||
const managerState = useManagerState()
|
||||
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
|
||||
})
|
||||
|
||||
it('should disable manager for unexpected server support flag values', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: systemStatsFixture([
|
||||
'python',
|
||||
'main.py',
|
||||
'--enable-manager'
|
||||
]),
|
||||
isInitialized: true
|
||||
})
|
||||
vi.mocked(api.getServerFeature).mockImplementation((name: string) => {
|
||||
if (name === 'extension.manager.supports_v4') return 'unexpected'
|
||||
if (name === 'extension.manager.supports_csrf_post') return true
|
||||
return undefined
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
|
||||
})
|
||||
})
|
||||
|
||||
describe('INCOMPATIBLE state (missing supports_csrf_post)', () => {
|
||||
const enabledManagerStats = () =>
|
||||
systemStatsFixture(['python', 'main.py', '--enable-manager'])
|
||||
|
||||
it('returns INCOMPATIBLE when server supports v4 but csrf_post is false', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: enabledManagerStats(),
|
||||
@@ -404,6 +369,9 @@ describe('useManagerState', () => {
|
||||
})
|
||||
|
||||
describe('helper properties', () => {
|
||||
const enabledManagerStats = () =>
|
||||
systemStatsFixture(['python', 'main.py', '--enable-manager'])
|
||||
|
||||
it('isManagerEnabled should return true when state is not DISABLED / INCOMPATIBLE', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: enabledManagerStats(),
|
||||
@@ -444,7 +412,12 @@ describe('useManagerState', () => {
|
||||
|
||||
it('isLegacyManagerUI should return true when state is LEGACY_UI', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: legacyManagerStats(),
|
||||
systemStats: systemStatsFixture([
|
||||
'python',
|
||||
'main.py',
|
||||
'--enable-manager',
|
||||
'--enable-manager-legacy-ui'
|
||||
]),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
@@ -480,119 +453,4 @@ describe('useManagerState', () => {
|
||||
expect(managerState.shouldShowManagerButtons.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openManager', () => {
|
||||
it('opens extension settings when manager is disabled', async () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: systemStatsFixture(['python', 'main.py']),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager()
|
||||
|
||||
expect(settingsShowMock).toHaveBeenCalledWith('extension')
|
||||
})
|
||||
|
||||
it('executes the default legacy manager command', async () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: legacyManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager()
|
||||
|
||||
expect(commandExecuteMock).toHaveBeenCalledWith(
|
||||
'Comfy.Manager.Menu.ToggleVisibility'
|
||||
)
|
||||
})
|
||||
|
||||
it('executes a custom legacy manager command', async () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: legacyManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager({ legacyCommand: 'Custom.Manager.Open' })
|
||||
|
||||
expect(commandExecuteMock).toHaveBeenCalledWith('Custom.Manager.Open')
|
||||
})
|
||||
|
||||
it('shows a toast when the legacy manager command is unavailable', async () => {
|
||||
commandExecuteMock.mockRejectedValueOnce(new Error('missing command'))
|
||||
systemStatsStore.$patch({
|
||||
systemStats: legacyManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager()
|
||||
|
||||
expect(toastAddMock).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'manager.legacyMenuNotAvailable'
|
||||
})
|
||||
expect(settingsShowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to extension settings when legacy errors suppress the toast', async () => {
|
||||
commandExecuteMock.mockRejectedValueOnce(new Error('missing command'))
|
||||
systemStatsStore.$patch({
|
||||
systemStats: legacyManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager({ showToastOnLegacyError: false })
|
||||
|
||||
expect(toastAddMock).not.toHaveBeenCalled()
|
||||
expect(settingsShowMock).toHaveBeenCalledWith('extension')
|
||||
})
|
||||
|
||||
it('opens the new manager dialog with initial routing options', async () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: enabledManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
|
||||
supports_manager_v4_ui: true
|
||||
})
|
||||
mockServerFeatures({ supports_v4: true, supports_csrf_post: true })
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.AllInstalled,
|
||||
initialPackId: 'pack-1'
|
||||
})
|
||||
|
||||
expect(managerDialogShowMock).toHaveBeenCalledWith(
|
||||
ManagerTab.AllInstalled,
|
||||
'pack-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows a legacy-only error instead of opening the new manager', async () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: enabledManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
|
||||
supports_manager_v4_ui: true
|
||||
})
|
||||
mockServerFeatures({ supports_v4: true, supports_csrf_post: true })
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager({ isLegacyOnly: true })
|
||||
|
||||
expect(toastAddMock).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'manager.legacyMenuNotAvailable'
|
||||
})
|
||||
expect(managerDialogShowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SortableField } from '@/types/searchServiceTypes'
|
||||
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
|
||||
|
||||
const mockSearchGateway = vi.hoisted(() => ({
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(
|
||||
(pack: Record<string, unknown>, field: string) => pack[field]
|
||||
),
|
||||
getSortableFields: vi.fn((): SortableField[] => [])
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const vue = await import('vue')
|
||||
return {
|
||||
watchDebounced: (
|
||||
source: unknown,
|
||||
callback: () => void,
|
||||
options?: { immediate?: boolean }
|
||||
) => {
|
||||
if (options?.immediate) callback()
|
||||
return vue.watch(source as Parameters<typeof vue.watch>[0], callback)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/gateway/registrySearchGateway', () => ({
|
||||
useRegistrySearchGateway: () => mockSearchGateway
|
||||
}))
|
||||
|
||||
function pack(name: string, downloads = 0) {
|
||||
return { id: name, name, downloads }
|
||||
}
|
||||
|
||||
async function flushSearch() {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('useRegistrySearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchGateway.searchPacks.mockResolvedValue({
|
||||
nodePacks: [],
|
||||
querySuggestions: []
|
||||
})
|
||||
mockSearchGateway.getSortableFields.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('runs the initial pack search with default paging and attributes', async () => {
|
||||
mockSearchGateway.searchPacks.mockResolvedValueOnce({
|
||||
nodePacks: [pack('alpha')],
|
||||
querySuggestions: [{ query: 'alpha' }]
|
||||
})
|
||||
|
||||
const search = useRegistrySearch({ initialSearchQuery: 'alp' })
|
||||
await flushSearch()
|
||||
|
||||
expect(mockSearchGateway.searchPacks).toHaveBeenCalledWith('alp', {
|
||||
pageSize: search.pageSize.value,
|
||||
pageNumber: 0,
|
||||
restrictSearchableAttributes: ['name', 'description']
|
||||
})
|
||||
expect(search.searchResults.value).toEqual([pack('alpha')])
|
||||
expect(search.suggestions.value).toEqual([{ query: 'alpha' }])
|
||||
expect(search.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uses node-search attributes when search mode is nodes', async () => {
|
||||
const search = useRegistrySearch({ initialSearchMode: 'nodes' })
|
||||
await flushSearch()
|
||||
|
||||
expect(mockSearchGateway.searchPacks).toHaveBeenCalledWith('', {
|
||||
pageSize: search.pageSize.value,
|
||||
pageNumber: 0,
|
||||
restrictSearchableAttributes: ['comfy_nodes']
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts manually when a non-default sort field is selected', async () => {
|
||||
mockSearchGateway.getSortableFields.mockReturnValue([
|
||||
{ id: 'name', label: 'Name', direction: 'asc' }
|
||||
])
|
||||
mockSearchGateway.searchPacks.mockResolvedValueOnce({
|
||||
nodePacks: [pack('zeta'), pack('alpha')],
|
||||
querySuggestions: []
|
||||
})
|
||||
|
||||
const search = useRegistrySearch({ initialSortField: 'name' })
|
||||
await flushSearch()
|
||||
|
||||
expect(search.searchResults.value.map((item) => item.name)).toEqual([
|
||||
'alpha',
|
||||
'zeta'
|
||||
])
|
||||
})
|
||||
|
||||
it('appends results when loading later pages', async () => {
|
||||
mockSearchGateway.searchPacks
|
||||
.mockResolvedValueOnce({
|
||||
nodePacks: [pack('first')],
|
||||
querySuggestions: []
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
nodePacks: [pack('second')],
|
||||
querySuggestions: []
|
||||
})
|
||||
|
||||
const search = useRegistrySearch()
|
||||
await flushSearch()
|
||||
|
||||
search.pageNumber.value = 1
|
||||
await flushSearch()
|
||||
|
||||
expect(search.searchResults.value.map((item) => item.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
expect(mockSearchGateway.searchPacks).toHaveBeenLastCalledWith('', {
|
||||
pageSize: search.pageSize.value,
|
||||
pageNumber: 1,
|
||||
restrictSearchableAttributes: ['name', 'description']
|
||||
})
|
||||
})
|
||||
|
||||
it('resets to the first page when sort field changes', async () => {
|
||||
mockSearchGateway.searchPacks
|
||||
.mockResolvedValueOnce({
|
||||
nodePacks: [pack('first')],
|
||||
querySuggestions: []
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
nodePacks: [pack('resorted')],
|
||||
querySuggestions: []
|
||||
})
|
||||
|
||||
const search = useRegistrySearch({ initialPageNumber: 2 })
|
||||
await flushSearch()
|
||||
|
||||
search.sortField.value = 'name'
|
||||
await flushSearch()
|
||||
|
||||
expect(search.pageNumber.value).toBe(0)
|
||||
expect(search.searchResults.value).toEqual([pack('resorted')])
|
||||
})
|
||||
|
||||
it('exposes sort options and clear cache from the gateway', () => {
|
||||
const fields: SortableField[] = [
|
||||
{ id: 'name', label: 'Name', direction: 'asc' }
|
||||
]
|
||||
mockSearchGateway.getSortableFields.mockReturnValue(fields)
|
||||
|
||||
const search = useRegistrySearch()
|
||||
search.clearCache()
|
||||
|
||||
expect(search.sortOptions.value).toBe(fields)
|
||||
expect(mockSearchGateway.clearSearchCache).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,281 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
||||
|
||||
const mockClient = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn()
|
||||
}))
|
||||
|
||||
const mockAxios = vi.hoisted(() => ({
|
||||
create: vi.fn(() => mockClient),
|
||||
isAxiosError: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
|
||||
clientId: 'client-1' as string | null,
|
||||
initialClientId: 'initial-client'
|
||||
}))
|
||||
|
||||
const mockManagerState = vi.hoisted(() => ({
|
||||
isNewManagerUI: { value: true }
|
||||
}))
|
||||
|
||||
const mockIsAbortError = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: mockAxios
|
||||
}))
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'generated-ui-id'
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: mockApi
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/typeGuardUtil', () => ({
|
||||
isAbortError: mockIsAbortError
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => mockManagerState
|
||||
}))
|
||||
|
||||
function axiosError(status: number, message?: string): unknown {
|
||||
return {
|
||||
response: {
|
||||
status,
|
||||
data: message ? { message } : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useComfyManagerService', () => {
|
||||
beforeEach(() => {
|
||||
mockClient.get.mockReset()
|
||||
mockClient.post.mockReset()
|
||||
mockAxios.isAxiosError.mockReset()
|
||||
mockAxios.isAxiosError.mockReturnValue(false)
|
||||
mockApi.apiURL.mockClear()
|
||||
mockManagerState.isNewManagerUI.value = true
|
||||
mockApi.clientId = 'client-1'
|
||||
mockApi.initialClientId = 'initial-client'
|
||||
mockIsAbortError.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('creates the manager API client with the v2 base URL', () => {
|
||||
expect(mockAxios.create).toHaveBeenCalledWith({
|
||||
baseURL: 'http://localhost:8188/v2/',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks requests when the new manager UI is unavailable', async () => {
|
||||
mockManagerState.isNewManagerUI.value = false
|
||||
const service = useComfyManagerService()
|
||||
|
||||
const result = await service.listInstalledPacks()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(service.error.value).toBe(
|
||||
'Manager service is not available in current mode'
|
||||
)
|
||||
expect(mockClient.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches installed packs and tracks loading state', async () => {
|
||||
mockClient.get.mockResolvedValueOnce({ data: { packs: [] } })
|
||||
const service = useComfyManagerService()
|
||||
|
||||
const promise = service.listInstalledPacks()
|
||||
expect(service.isLoading.value).toBe(true)
|
||||
await expect(promise).resolves.toEqual({ packs: [] })
|
||||
expect(service.isLoading.value).toBe(false)
|
||||
expect(service.error.value).toBeNull()
|
||||
expect(mockClient.get).toHaveBeenCalledWith('customnode/installed', {
|
||||
signal: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('passes queue status query params only when a client id is provided', async () => {
|
||||
mockClient.get
|
||||
.mockResolvedValueOnce({ data: { running: true } })
|
||||
.mockResolvedValueOnce({ data: { running: false } })
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.getQueueStatus('client-a')
|
||||
await service.getQueueStatus()
|
||||
|
||||
expect(mockClient.get).toHaveBeenNthCalledWith(1, 'manager/queue/status', {
|
||||
params: { client_id: 'client-a' },
|
||||
signal: undefined
|
||||
})
|
||||
expect(mockClient.get).toHaveBeenNthCalledWith(2, 'manager/queue/status', {
|
||||
params: undefined,
|
||||
signal: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty bulk import result without making a request when no ids or urls are provided', async () => {
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await expect(service.getImportFailInfoBulk()).resolves.toEqual({})
|
||||
|
||||
expect(mockClient.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('posts bulk import failure requests when ids are provided', async () => {
|
||||
mockClient.post.mockResolvedValueOnce({ data: { failed: [] } })
|
||||
const service = useComfyManagerService()
|
||||
const params = { cnr_ids: ['pack'] } as Parameters<
|
||||
typeof service.getImportFailInfoBulk
|
||||
>[0]
|
||||
|
||||
await expect(service.getImportFailInfoBulk(params)).resolves.toEqual({
|
||||
failed: []
|
||||
})
|
||||
expect(mockClient.post).toHaveBeenCalledWith(
|
||||
'customnode/import_fail_info_bulk',
|
||||
params,
|
||||
{ signal: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('queues install tasks with generated ids and starts the queue', async () => {
|
||||
mockClient.post
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
const service = useComfyManagerService()
|
||||
const params = { id: 'pack-id' } as Parameters<
|
||||
typeof service.installPack
|
||||
>[0]
|
||||
|
||||
await expect(service.installPack(params)).resolves.toBeNull()
|
||||
|
||||
expect(mockClient.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'manager/queue/task',
|
||||
{
|
||||
kind: 'install',
|
||||
params,
|
||||
ui_id: 'generated-ui-id',
|
||||
client_id: 'client-1'
|
||||
},
|
||||
{ signal: undefined }
|
||||
)
|
||||
expect(mockClient.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'manager/queue/start',
|
||||
null,
|
||||
{ signal: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses initial client id when queueing and the current client id is absent', async () => {
|
||||
mockApi.clientId = null
|
||||
mockClient.post
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
const service = useComfyManagerService()
|
||||
const params: Parameters<typeof service.updatePack>[0] = {
|
||||
node_name: 'pack-id'
|
||||
}
|
||||
|
||||
await service.updatePack(params, 'ui-id')
|
||||
|
||||
expect(mockClient.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'manager/queue/task',
|
||||
expect.objectContaining({
|
||||
kind: 'update',
|
||||
ui_id: 'ui-id',
|
||||
client_id: 'initial-client'
|
||||
}),
|
||||
{ signal: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('posts update all requests with query params and starts the queue', async () => {
|
||||
mockClient.post
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.updateAllPacks({ mode: 'remote' }, 'ui-id')
|
||||
|
||||
expect(mockClient.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'manager/queue/update_all',
|
||||
null,
|
||||
{
|
||||
params: {
|
||||
mode: 'remote',
|
||||
client_id: 'client-1',
|
||||
ui_id: 'ui-id'
|
||||
},
|
||||
signal: undefined
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('maps route-specific axios errors', async () => {
|
||||
mockAxios.isAxiosError.mockReturnValueOnce(true)
|
||||
mockClient.post.mockRejectedValueOnce(axiosError(403))
|
||||
const service = useComfyManagerService()
|
||||
const params = { id: 'pack-id' } as Parameters<
|
||||
typeof service.installPack
|
||||
>[0]
|
||||
|
||||
await expect(service.installPack(params)).resolves.toBeNull()
|
||||
|
||||
expect(service.error.value).toBe(
|
||||
'Forbidden: A security error has occurred. Please check the terminal logs'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps manager connectivity axios errors', async () => {
|
||||
mockAxios.isAxiosError.mockReturnValueOnce(true)
|
||||
mockClient.get.mockRejectedValueOnce(axiosError(404))
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.listInstalledPacks()
|
||||
|
||||
expect(service.error.value).toBe('Could not connect to ComfyUI-Manager')
|
||||
})
|
||||
|
||||
it('uses response messages from generic axios errors', async () => {
|
||||
mockAxios.isAxiosError.mockReturnValueOnce(true)
|
||||
mockClient.get.mockRejectedValueOnce(axiosError(500, 'server exploded'))
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.getTaskHistory()
|
||||
|
||||
expect(service.error.value).toBe('server exploded')
|
||||
})
|
||||
|
||||
it('uses thrown error messages for non-axios errors', async () => {
|
||||
mockClient.get.mockRejectedValueOnce(new Error('network down'))
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.getImportFailInfo()
|
||||
|
||||
expect(service.error.value).toBe(
|
||||
'Fetching import failure information failed: network down'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not set an error for aborted requests', async () => {
|
||||
mockIsAbortError.mockReturnValueOnce(true)
|
||||
mockClient.get.mockRejectedValueOnce(new Error('aborted'))
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.isLegacyManagerUI()
|
||||
|
||||
expect(service.error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -13,26 +13,6 @@ type ManagerChannel = ManagerComponents['schemas']['ManagerChannel']
|
||||
type ManagerDatabaseSource =
|
||||
ManagerComponents['schemas']['ManagerDatabaseSource']
|
||||
type ManagerPackInstalled = ManagerComponents['schemas']['ManagerPackInstalled']
|
||||
type TaskHistoryItem = ManagerComponents['schemas']['TaskHistoryItem']
|
||||
|
||||
const { mockAppApi, mockClientId } = vi.hoisted(() => ({
|
||||
mockAppApi: new EventTarget(),
|
||||
mockClientId: { value: 'client-id' }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
api: mockAppApi
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
get clientId() {
|
||||
return mockClientId.value
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
|
||||
useComfyManagerService: vi.fn()
|
||||
@@ -99,7 +79,6 @@ describe('useComfyManagerStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
mockClientId.value = 'client-id'
|
||||
mockManagerService = {
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
@@ -522,229 +501,4 @@ describe('useComfyManagerStore', () => {
|
||||
expect(store.isPackInstalled('disabled-pack')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('task state', () => {
|
||||
it('cleans installing state when manager reports task completion', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await store.installPack.call({
|
||||
id: 'event-pack',
|
||||
repository: 'https://github.com/test/event-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
const taskId = vi.mocked(mockManagerService.installPack).mock.calls[0][1]
|
||||
|
||||
mockAppApi.dispatchEvent(
|
||||
new CustomEvent('cm-task-completed', { detail: {} })
|
||||
)
|
||||
mockAppApi.dispatchEvent(
|
||||
new CustomEvent('cm-task-completed', {
|
||||
detail: { ui_id: 'unknown-task' }
|
||||
})
|
||||
)
|
||||
expect(store.isPackInstalling('event-pack')).toBe(true)
|
||||
|
||||
mockAppApi.dispatchEvent(
|
||||
new CustomEvent('cm-task-completed', { detail: { ui_id: taskId } })
|
||||
)
|
||||
|
||||
expect(store.isPackInstalling('event-pack')).toBe(false)
|
||||
})
|
||||
|
||||
it('partitions task ids and logs by task status', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
store.taskLogs = [
|
||||
{ taskName: 'Success', taskId: 'success', logs: [] },
|
||||
{ taskName: 'Failed', taskId: 'failed', logs: [] },
|
||||
{ taskName: 'Unknown', taskId: 'unknown', logs: [] }
|
||||
]
|
||||
store.taskHistory = {
|
||||
success: {
|
||||
ui_id: 'success',
|
||||
status: { status_str: 'success' }
|
||||
} as TaskHistoryItem,
|
||||
failed: {
|
||||
ui_id: 'failed',
|
||||
status: { status_str: 'error' }
|
||||
} as TaskHistoryItem,
|
||||
unknown: {
|
||||
ui_id: 'unknown'
|
||||
} as TaskHistoryItem
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(store.succeededTasksIds).toEqual(['success'])
|
||||
expect(store.failedTasksIds).toEqual(['failed', 'unknown'])
|
||||
expect(store.succeededTasksLogs.map((log) => log.taskId)).toEqual([
|
||||
'success'
|
||||
])
|
||||
expect(store.failedTasksLogs.map((log) => log.taskId)).toEqual([
|
||||
'failed',
|
||||
'unknown'
|
||||
])
|
||||
})
|
||||
|
||||
it('records client-side task errors with fallback client ids and messages', async () => {
|
||||
mockClientId.value = ''
|
||||
vi.mocked(mockManagerService.installPack).mockRejectedValueOnce(
|
||||
new Error('install failed')
|
||||
)
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await store.installPack.call({
|
||||
id: 'error-pack',
|
||||
repository: 'https://github.com/test/error-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
const taskId = vi.mocked(mockManagerService.installPack).mock.calls[0][1]
|
||||
if (!taskId) throw new Error('expected install task id to be generated')
|
||||
|
||||
expect(store.taskHistory[taskId].client_id).toBe('unknown')
|
||||
expect(store.taskHistory[taskId].status?.messages).toEqual([
|
||||
'install failed'
|
||||
])
|
||||
expect(store.isProcessingTasks).toBe(false)
|
||||
})
|
||||
|
||||
it('records string task errors', async () => {
|
||||
vi.mocked(mockManagerService.installPack).mockRejectedValueOnce(
|
||||
'install failed'
|
||||
)
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await store.installPack.call({
|
||||
id: 'string-error-pack',
|
||||
repository: 'https://github.com/test/string-error-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
const taskId = vi.mocked(mockManagerService.installPack).mock.calls[0][1]
|
||||
if (!taskId) throw new Error('expected install task id to be generated')
|
||||
|
||||
expect(store.taskHistory[taskId].status?.messages).toEqual([
|
||||
'install failed'
|
||||
])
|
||||
})
|
||||
|
||||
it('resets task state and clears logs', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
await store.installPack.call({
|
||||
id: 'reset-pack',
|
||||
repository: 'https://github.com/test/reset-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
store.clearLogs()
|
||||
store.taskHistory = {
|
||||
success: {
|
||||
ui_id: 'success',
|
||||
status: { status_str: 'success' }
|
||||
} as TaskHistoryItem
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
store.resetTaskState()
|
||||
|
||||
expect(store.taskLogs).toEqual([])
|
||||
expect(store.taskHistory).toEqual({})
|
||||
expect(store.succeededTasksIds).toEqual([])
|
||||
expect(store.isPackInstalling('reset-pack')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('ignores installed entries without pack ids', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await triggerPacksChange(
|
||||
{
|
||||
nameless: {
|
||||
enabled: true,
|
||||
cnr_id: undefined,
|
||||
aux_id: undefined,
|
||||
ver: '1.0.0'
|
||||
}
|
||||
},
|
||||
store
|
||||
)
|
||||
|
||||
expect(store.installedPacksIds.size).toBe(0)
|
||||
expect(store.isPackInstalled('nameless')).toBe(false)
|
||||
})
|
||||
|
||||
it('marks the store fresh when refresh returns no packs', async () => {
|
||||
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(null)
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await store.refreshInstalledList()
|
||||
|
||||
expect(store.installedPacks).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores install requests without ids', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await store.installPack.call({
|
||||
id: '',
|
||||
repository: 'https://github.com/test/missing-id',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
|
||||
expect(mockManagerService.installPack).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles installed package install actions for version changes and enabling', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
await triggerPacksChange(
|
||||
{
|
||||
'change-pack': {
|
||||
enabled: true,
|
||||
cnr_id: 'change-pack',
|
||||
aux_id: undefined,
|
||||
ver: '1.0.0'
|
||||
},
|
||||
'enable-pack': {
|
||||
enabled: true,
|
||||
cnr_id: 'enable-pack',
|
||||
aux_id: undefined,
|
||||
ver: 'latest'
|
||||
}
|
||||
},
|
||||
store
|
||||
)
|
||||
|
||||
await store.installPack.call({
|
||||
id: 'change-pack',
|
||||
repository: 'https://github.com/test/change-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: '2.0.0',
|
||||
version: '2.0.0'
|
||||
})
|
||||
await store.installPack.call({
|
||||
id: 'enable-pack',
|
||||
repository: 'https://github.com/test/enable-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
|
||||
expect(mockManagerService.installPack).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import { extractCustomNodeName, getNodeHelpBaseUrl } from './nodeHelpUtil'
|
||||
|
||||
function nodeDef(name: string, python_module: string): ComfyNodeDefImpl {
|
||||
return { name, python_module } as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
describe('nodeHelpUtil', () => {
|
||||
it('extracts normalized custom node package names', () => {
|
||||
expect(
|
||||
extractCustomNodeName('custom_nodes.ComfyUI-TestPack@1.2.3.nodes')
|
||||
).toBe('ComfyUI-TestPack')
|
||||
expect(extractCustomNodeName('nodes')).toBeNull()
|
||||
expect(extractCustomNodeName(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns base URLs for blueprint, custom, and core nodes', () => {
|
||||
expect(
|
||||
getNodeHelpBaseUrl(nodeDef('SubgraphBlueprint.Test', 'blueprint'))
|
||||
).toBe('')
|
||||
expect(
|
||||
getNodeHelpBaseUrl(nodeDef('CustomNode', 'custom_nodes.TestPack.nodes'))
|
||||
).toBe('/extensions/TestPack/docs/')
|
||||
expect(getNodeHelpBaseUrl(nodeDef('LoadImage', 'nodes'))).toBe(
|
||||
'/docs/LoadImage/'
|
||||
)
|
||||
expect(getNodeHelpBaseUrl(nodeDef('UnknownNode', 'custom_nodes'))).toBe(
|
||||
'/docs/UnknownNode/'
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user