Compare commits

...

2 Commits

Author SHA1 Message Date
huang47
38c6fd9b88 test: cover core settings and renderer 2026-07-02 09:29:53 -07:00
Benjamin Lu
2ec2a0e091 feat: attribute payment intent through paywall, checkout, and top-up telemetry (#13363)
## Summary

Answers "why did this user want to pay?" by capturing the triggering
product moment at every paywall/upsell entry point and carrying it
through checkout and success telemetry.

## Changes

- **What**:
- Widen `SubscriptionDialogReason` from 4 coarse values to 13 grounded
intent sources (`subscribe_to_run`, `upgrade_to_add_credits`,
`invite_member_upsell`, `settings_billing_panel`, etc.)
- Fire `app:subscription_required_modal_opened` from
`useSubscriptionDialog` (the choke point all dialog variants pass
through) — the workspace/unified path previously emitted nothing; remove
the now-duplicate emitters in `useSubscription` and
`usePricingTableUrlLoader`
- Add `payment_intent_source` to
`BeginCheckoutMetadata`/`SubscriptionSuccessMetadata`, threaded via the
existing `reason` prop: dialog → `PricingTable` →
`performSubscriptionCheckout` → pending-attempt record, so legacy
`app:monthly_subscription_succeeded` carries intent alongside
`checkout_attempt_id`
- Fire `begin_checkout` on the workspace checkout path
(`useSubscriptionCheckout`, personal + team confirm) and the team
deep-link util — both previously emitted nothing; `tier` widened to
`TierKey | 'team'`
- Implement `trackBeginCheckout` in `PostHogTelemetryProvider` (was
GTM/host-only, so `begin_checkout` never reached PostHog)
- Thread `showSubscriptionDialog(options)` through the billing-context
adapters and pass a reason at ~14 call sites; add `source` to
`app:add_api_credit_button_clicked`

## Review Focus

- `modal_opened` now fires once per dialog actually shown, so a
free-tier user clicking Upgrade emits two events (free-tier dialog, then
pricing table) where the legacy path emitted one
- Intent is threaded explicitly via props/params rather than shared
state; `useSubscriptionCheckout` gained an optional second parameter
2026-07-02 03:11:21 +00:00
59 changed files with 3071 additions and 233 deletions

View File

@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
emit('close')
}
@@ -239,8 +239,7 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
dialogService.showTopUpCreditsDialog()
emit('close')
}
@@ -254,7 +253,7 @@ const handleOpenPartnerNodesInfo = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
emit('close')
}

View File

@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function handleClick() {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
}
</script>

View File

@@ -1,5 +1,6 @@
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,
@@ -75,9 +76,10 @@ export interface BillingActions {
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog.
* Shows the subscription dialog. Pass a reason so the paywall open and any
* downstream checkout stay attributed to the triggering product moment.
*/
showSubscriptionDialog: () => void
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
}
export interface BillingState {

View File

@@ -7,6 +7,7 @@ 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
@@ -281,8 +282,8 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
return activeContext.value.showSubscriptionDialog(options)
}
return {

View File

@@ -2,6 +2,7 @@ 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,
@@ -189,12 +190,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog()
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
}
}
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
legacyShowSubscriptionDialog(options)
}
return {

View File

@@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}

View File

@@ -25,6 +25,6 @@ function handleClose() {
}
function handleSubscribe() {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'upload_model_upgrade' })
}
</script>

View File

@@ -140,7 +140,10 @@ describe('CloudSubscriptionRedirectView', () => {
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
false
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
)
// Shows loading affordances
@@ -169,7 +172,10 @@ describe('CloudSubscriptionRedirectView', () => {
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
false
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
)
})
@@ -180,7 +186,8 @@ describe('CloudSubscriptionRedirectView', () => {
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
'team_700',
'yearly'
'yearly',
{ paymentIntentSource: 'deep_link' }
)
// Team never goes through the personal checkout path
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()

View File

@@ -94,7 +94,9 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
isTeamCheckout.value = true
await performTeamSubscriptionCheckout(stopId, billingCycle)
await performTeamSubscriptionCheckout(stopId, billingCycle, {
paymentIntentSource: 'deep_link'
})
return
}
@@ -112,7 +114,10 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
if (isActiveSubscription.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
await performSubscriptionCheckout(tierKeyParam, billingCycle, {
openInNewTab: false,
paymentIntentSource: 'deep_link'
})
}
}, reportError)

View File

@@ -351,12 +351,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => {
})
function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked()
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
void dialogService.showTopUpCreditsDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable()
showPricingTable({ reason: 'upgrade_to_add_credits' })
}
async function handleWindowFocus() {

View File

@@ -5,6 +5,8 @@ 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 }))
@@ -15,7 +17,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
}))
}))
function renderComponent() {
function renderComponent(props?: { reason?: PaymentIntentSource }) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -23,6 +25,7 @@ function renderComponent() {
})
return render(FreeTierDialogContent, {
props,
global: {
plugins: [i18n]
}
@@ -43,4 +46,18 @@ 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()
})
})

View File

@@ -52,7 +52,7 @@
</p>
<p
v-if="!reason || reason === 'subscription_required'"
v-if="!isCreditsBlockedVariant"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -65,10 +65,7 @@
</p>
<p
v-if="
(!reason || reason === 'subscription_required') &&
formattedRenewalDate
"
v-if="!isCreditsBlockedVariant && formattedRenewalDate"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -88,7 +85,7 @@
@click="$emit('upgrade')"
>
{{
reason === 'out_of_credits' || reason === 'top_up_blocked'
isCreditsBlockedVariant
? $t('subscription.freeTier.upgradeCta')
: $t('subscription.freeTier.subscribeCta')
}}
@@ -103,12 +100,12 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
defineProps<{
reason?: SubscriptionDialogReason
const { reason } = defineProps<{
reason?: PaymentIntentSource
}>()
defineEmits<{
@@ -129,4 +126,10 @@ 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>

View File

@@ -261,6 +261,7 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
@@ -341,6 +342,7 @@ 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 () => {
@@ -366,6 +368,7 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
})

View File

@@ -277,13 +277,19 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import {
recordPendingSubscriptionCheckoutAttempt,
withPendingCheckoutAttemptId
} 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 } from '@/platform/telemetry/types'
import type {
CheckoutAttributionMetadata,
PaymentIntentSource
} from '@/platform/telemetry/types'
import { useAuthStore } from '@/stores/authStore'
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
@@ -321,6 +327,10 @@ interface PricingTierConfig {
isPopular?: boolean
}
const { reason } = defineProps<{
reason?: PaymentIntentSource
}>()
const emit = defineEmits<{
chooseTeamWorkspace: []
}>()
@@ -463,16 +473,17 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
} as const
const previousPlan = currentPlanDescriptor.value
const checkoutAttribution = await getCheckoutAttributionForCloud()
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...checkoutAttribution,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
})
}
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
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(
targetPlan.tierKey,
@@ -487,29 +498,39 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
if (downgrade) {
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
await accessBillingPortal()
const didOpenPortal = await accessBillingPortal()
if (didOpenPortal && beginCheckoutMetadata) {
telemetry?.trackBeginCheckout(beginCheckoutMetadata)
}
} else {
const didOpenPortal = await accessBillingPortal(checkoutTier)
if (!didOpenPortal) {
return
}
recordPendingSubscriptionCheckoutAttempt({
const pendingAttempt = 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,
true
)
await performSubscriptionCheckout(tierKey, currentBillingCycle.value, {
paymentIntentSource: reason
})
}
} finally {
isLoading.value = false

View File

@@ -56,7 +56,7 @@ const handleSubscribe = () => {
current_tier: tier.value?.toLowerCase()
})
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_now_button' })
}
onBeforeUnmount(() => {

View File

@@ -54,6 +54,6 @@ function handleSubscribeToRun() {
trackRunButton({ subscribe_to_run: true })
}
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
}
</script>

View File

@@ -48,7 +48,9 @@
v-if="isActiveSubscription"
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
@click="
showSubscriptionDialog({ reason: 'settings_billing_panel' })
"
>
{{ $t('subscription.upgradePlan') }}
</Button>

View File

@@ -33,7 +33,11 @@
</i18n-t>
</div>
<PricingTable class="flex-1" @choose-team-workspace="handleChooseTeam" />
<PricingTable
:reason
class="flex-1"
@choose-team-workspace="handleChooseTeam"
/>
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center gap-2">
@@ -157,11 +161,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 { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
const { onClose, reason, onChooseTeam } = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
onChooseTeam?: () => void
}>()

View File

@@ -24,7 +24,9 @@ export function useAccountPreconditionDialog() {
)
return
case 'subscription':
void dialogService.showSubscriptionRequiredDialog()
void dialogService.showSubscriptionRequiredDialog({
reason: 'subscription_required'
})
return
case 'credits':
void dialogService.showTopUpCreditsDialog({

View File

@@ -55,12 +55,6 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
})
}))
const mockTrackSubscription = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
describe('usePricingTableUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -96,9 +90,6 @@ describe('usePricingTableUrlLoader', () => {
reason: 'deep_link',
planMode: undefined
})
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
reason: 'deep_link'
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})
@@ -150,7 +141,6 @@ 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 () => {
@@ -161,7 +151,6 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
@@ -230,7 +219,6 @@ describe('usePricingTableUrlLoader', () => {
)
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'

View File

@@ -7,7 +7,6 @@ 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'
@@ -62,7 +61,6 @@ export function usePricingTableUrlLoader() {
const planMode =
param === 'team' || param === 'personal' ? param : undefined
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
}

View File

@@ -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 { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogOptions } 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,14 +237,7 @@ function useSubscriptionInternal() {
})
}, reportError)
const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => {
void showSubscriptionRequiredDialog(options)
}
@@ -277,7 +270,7 @@ function useSubscriptionInternal() {
await fetchSubscriptionStatus()
if (!isSubscribedOrIsNotCloud.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscription_required' })
}
}

View File

@@ -39,15 +39,23 @@ vi.mock('@/stores/commandStore', () => ({
}))
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
const {
mockIsCloud,
mockTrackHelpResourceClicked,
mockTrackAddApiCreditButtonClicked
} = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockTrackHelpResourceClicked: vi.fn()
mockTrackHelpResourceClicked: vi.fn(),
mockTrackAddApiCreditButtonClicked: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () =>
mockIsCloud.value
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
? {
trackHelpResourceClicked: mockTrackHelpResourceClicked,
trackAddApiCreditButtonClicked: mockTrackAddApiCreditButtonClicked
}
: null
}))
@@ -69,6 +77,9 @@ describe('useSubscriptionActions', () => {
const { handleAddApiCredits } = useSubscriptionActions()
handleAddApiCredits()
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
expect(mockTrackAddApiCreditButtonClicked).toHaveBeenCalledWith({
source: 'settings_billing_panel'
})
})
})

View File

@@ -21,6 +21,9 @@ export function useSubscriptionActions() {
})
const handleAddApiCredits = () => {
telemetry?.trackAddApiCreditButtonClicked({
source: 'settings_billing_panel'
})
void dialogService.showTopUpCreditsDialog()
}

View File

@@ -5,8 +5,10 @@ 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 }))
@@ -60,10 +62,15 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isFreeTier: mockIsFreeTier,
isLegacyTeamPlan: mockIsLegacyTeamPlan
isLegacyTeamPlan: mockIsLegacyTeamPlan,
tier: mockTier
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
@@ -80,6 +87,7 @@ describe('useSubscriptionDialog', () => {
mockIsCloud.value = true
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTier.value = 'FREE'
mockTeamWorkspacesEnabled.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -198,6 +206,51 @@ 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', () => {
@@ -235,6 +288,20 @@ 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', () => {

View File

@@ -4,6 +4,8 @@ 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'
@@ -11,14 +13,8 @@ const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
export interface SubscriptionDialogOptions {
reason?: PaymentIntentSource
/**
* Forces the unified pricing dialog to open on a specific plan tab,
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
@@ -38,6 +34,17 @@ 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
@@ -71,6 +78,8 @@ export const useSubscriptionDialog = () => {
return
}
trackModalOpened(options?.reason)
// Shared dialog shell styling for both variants.
const dialogComponentProps = {
style: 'width: min(1328px, 95vw); max-height: 958px;',
@@ -167,6 +176,8 @@ 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')
@@ -236,7 +247,7 @@ export const useSubscriptionDialog = () => {
sessionStorage.removeItem(RESUME_PRICING_KEY)
if (!workspaceStore.isInPersonalWorkspace) {
showPricingTable()
showPricingTable({ reason: 'team_upgrade_resume' })
}
} catch {
// sessionStorage may be unavailable

View File

@@ -0,0 +1,49 @@
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')
})
})

View File

@@ -7,7 +7,12 @@ import type {
TierKey
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
import type {
BeginCheckoutMetadata,
PaymentIntentSource,
SubscriptionCheckoutType,
SubscriptionSuccessMetadata
} from '@/platform/telemetry/types'
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
const VALID_TIER_KEYS = new Set<TierKey>([
@@ -23,7 +28,6 @@ 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 {
@@ -32,22 +36,24 @@ interface SubscriptionStatusSnapshot {
subscription_duration?: SubscriptionDuration | null
}
interface PendingSubscriptionCheckoutAttempt {
export interface PendingSubscriptionCheckoutAttempt {
attempt_id: string
started_at_ms: number
tier: TierKey
cycle: BillingCycle
checkout_type: CheckoutType
checkout_type: SubscriptionCheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
interface RecordPendingSubscriptionCheckoutAttemptInput {
interface PendingSubscriptionCheckoutAttemptInput {
tier: TierKey
cycle: BillingCycle
checkout_type: CheckoutType
checkout_type: SubscriptionCheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
const dispatchPendingCheckoutChangeEvent = () => {
@@ -168,6 +174,9 @@ 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 }
: {})
}
}
@@ -224,20 +233,27 @@ const getPendingSubscriptionCheckoutAttempt =
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
getPendingSubscriptionCheckoutAttempt() !== null
export const recordPendingSubscriptionCheckoutAttempt = (
input: RecordPendingSubscriptionCheckoutAttemptInput
export const createPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
const attempt: PendingSubscriptionCheckoutAttempt = {
return {
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.previous_cycle ? { previous_cycle: input.previous_cycle } : {}),
...(input.payment_intent_source
? { payment_intent_source: input.payment_intent_source }
: {})
}
}
export const persistPendingSubscriptionCheckoutAttempt = (
attempt: PendingSubscriptionCheckoutAttempt
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
if (!storage) {
return attempt
}
@@ -255,6 +271,21 @@ export const recordPendingSubscriptionCheckoutAttempt = (
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
@@ -287,6 +318,9 @@ 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: {

View File

@@ -132,13 +132,14 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'yearly', true)
await performSubscriptionCheckout('pro', 'yearly')
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',
@@ -150,6 +151,12 @@ 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'
@@ -186,7 +193,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', true)
await performSubscriptionCheckout('pro', 'monthly')
expect(warnSpy).toHaveBeenCalledWith(
'[SubscriptionCheckout] Failed to collect checkout attribution',
@@ -203,11 +210,43 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-123',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
})
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
@@ -222,7 +261,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly')
mockUserId.value = 'user-late'
authHeader.resolve({ Authorization: 'Bearer test-token' })
@@ -235,13 +274,14 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-late',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new'
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
})
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
it('does not persist the pending attempt when the checkout popup is blocked', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
@@ -250,11 +290,18 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', true)
await performSubscriptionCheckout('pro', 'monthly')
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
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)
})
)
})
})

View File

@@ -4,12 +4,19 @@ 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`
@@ -31,6 +38,11 @@ const getCheckoutAttributionForCloud =
return getCheckoutAttribution()
}
interface PerformSubscriptionCheckoutOptions {
openInNewTab?: boolean
paymentIntentSource?: PaymentIntentSource
}
/**
* Core subscription checkout logic shared between PricingTable and
* SubscriptionRedirectView. Handles:
@@ -47,10 +59,12 @@ const getCheckoutAttributionForCloud =
export async function performSubscriptionCheckout(
tierKey: TierKey,
currentBillingCycle: BillingCycle,
openInNewTab: boolean = true
options: PerformSubscriptionCheckoutOptions = {}
): Promise<void> {
if (!isCloud) return
const { openInNewTab = true, paymentIntentSource } = options
const authStore = useAuthStore()
const { userId } = storeToRefs(authStore)
const telemetry = useTelemetry()
@@ -108,14 +122,29 @@ 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({
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...checkoutAttribution
})
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(
{
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...(paymentIntentSource
? { payment_intent_source: paymentIntentSource }
: {}),
...checkoutAttribution
},
pendingAttempt
)
)
}
if (openInNewTab) {
@@ -123,18 +152,9 @@ export async function performSubscriptionCheckout(
if (!checkoutWindow) {
return
}
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
} else {
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
globalThis.location.href = data.checkout_url
}
}

View File

@@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, reactive } from 'vue'
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn()
}))
const { mockIsCloud, mockSubscribe, mockTrackBeginCheckout, mockUserId } =
vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn(),
mockTrackBeginCheckout: vi.fn(),
mockUserId: { value: 'user-1' as string | null }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -16,6 +20,12 @@ 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'
@@ -43,7 +53,9 @@ describe('performTeamSubscriptionCheckout', () => {
billing_op_id: 'op_1'
})
await performTeamSubscriptionCheckout('team_700', 'yearly')
await performTeamSubscriptionCheckout('team_700', 'yearly', {
paymentIntentSource: 'deep_link'
})
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
returnUrl: 'https://app.test/payment/success',
@@ -51,6 +63,14 @@ 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 () => {
@@ -82,6 +102,16 @@ 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

View File

@@ -1,10 +1,16 @@
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
@@ -22,7 +28,8 @@ import type { BillingCycle } from './subscriptionTierRank'
*/
export async function performTeamSubscriptionCheckout(
teamCreditStopId: string,
billingCycle: BillingCycle
billingCycle: BillingCycle,
options: PerformTeamSubscriptionCheckoutOptions = {}
): Promise<void> {
if (!isCloud) return
@@ -33,6 +40,14 @@ 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

View File

@@ -0,0 +1,180 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ColorPaletteMessage from './ColorPaletteMessage.vue'
import type * as Pinia from 'pinia'
const mockSettingStore = vi.hoisted(() => ({
set: vi.fn()
}))
const mockColorPaletteService = vi.hoisted(() => ({
exportColorPalette: vi.fn(),
importColorPalette: vi.fn(),
deleteCustomColorPalette: vi.fn()
}))
const mockColorPaletteState = vi.hoisted(() => ({
refs: null as null | {
palettes: {
value: Array<{ id: string; name: string }>
}
activePaletteId: {
value: string
}
},
customPaletteIds: new Set<string>()
}))
vi.mock('pinia', async (importOriginal: () => Promise<typeof Pinia>) => {
const actual = await importOriginal()
return {
...actual,
storeToRefs: (store: object) => store
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
vi.mock('@/services/colorPaletteService', () => ({
useColorPaletteService: () => mockColorPaletteService
}))
vi.mock('@/stores/workspace/colorPaletteStore', async () => {
const { ref } = await import('vue')
const palettes = ref([
{ id: 'builtin', name: 'Builtin' },
{ id: 'custom', name: 'Custom' }
])
const activePaletteId = ref('builtin')
mockColorPaletteState.refs = {
palettes,
activePaletteId
}
return {
useColorPaletteStore: () => ({
palettes,
activePaletteId,
isCustomPalette: (paletteId: string) =>
mockColorPaletteState.customPaletteIds.has(paletteId)
})
}
})
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
props: ['title', 'disabled'],
emits: ['click'],
template: `
<button
type="button"
:title="title"
:disabled="disabled"
@click="$emit('click')"
>
<slot />
</button>
`
}
}))
vi.mock('primevue/message', () => ({
default: {
template: '<section><slot /></section>'
}
}))
vi.mock('primevue/select', () => ({
default: {
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template: `
<select
data-testid="palette-select"
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="option in options" :key="option.id" :value="option.id">
{{ option.name }}
</option>
</select>
`
}
}))
function renderMessage() {
return render(ColorPaletteMessage, {
global: {
config: {
globalProperties: fromAny({
$t: (key: string) => key
})
}
}
})
}
describe('ColorPaletteMessage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingStore.set.mockResolvedValue(undefined)
mockColorPaletteService.importColorPalette.mockResolvedValue(null)
mockColorPaletteState.customPaletteIds = new Set(['custom'])
if (mockColorPaletteState.refs) {
mockColorPaletteState.refs.activePaletteId.value = 'builtin'
mockColorPaletteState.refs.palettes.value = [
{ id: 'builtin', name: 'Builtin' },
{ id: 'custom', name: 'Custom' }
]
}
})
it('exports and deletes the active custom palette', async () => {
renderMessage()
await userEvent.click(screen.getByTitle('g.export'))
expect(mockColorPaletteService.exportColorPalette).toHaveBeenCalledWith(
'builtin'
)
expect(screen.getByTitle('g.delete')).toBeDisabled()
await fireEvent.update(screen.getByTestId('palette-select'), 'custom')
await userEvent.click(screen.getByTitle('g.delete'))
expect(
mockColorPaletteService.deleteCustomColorPalette
).toHaveBeenCalledWith('custom')
})
it('persists imported palettes only when import returns a palette', async () => {
renderMessage()
await userEvent.click(screen.getByTitle('g.import'))
expect(mockSettingStore.set).not.toHaveBeenCalled()
mockColorPaletteService.importColorPalette.mockResolvedValue({
id: 'imported',
name: 'Imported'
})
await userEvent.click(screen.getByTitle('g.import'))
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.ColorPalette',
'imported'
)
})
})

View File

@@ -0,0 +1,312 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ExtensionPanel from './ExtensionPanel.vue'
interface MockExtension {
name: string
}
const mockSettingStore = vi.hoisted(() => ({
set: vi.fn()
}))
const mockExtensionState = vi.hoisted(() => ({
store: {
extensions: [
{ name: 'core.color' },
{ name: 'custom.pack' },
{ name: 'readonly.pack' }
] as MockExtension[],
inactiveDisabledExtensionNames: ['inactive.pack'],
hasThirdPartyExtensions: true,
enabled: new Set(['core.color', 'custom.pack', 'readonly.pack']),
core: new Set(['core.color']),
readOnly: new Set(['readonly.pack']),
isExtensionEnabled(name: string) {
return this.enabled.has(name)
},
isCoreExtension(name: string) {
return this.core.has(name)
},
isExtensionReadOnly(name: string) {
return this.readOnly.has(name)
}
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, string>) =>
params ? `${key}:${params.subject}` : key
})
}))
vi.mock('@primevue/core/api', () => ({
FilterMatchMode: {
CONTAINS: 'contains'
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => mockExtensionState.store
}))
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue'],
template: `
<input
data-testid="extension-search"
:value="modelValue"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
/>
`
}
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
props: ['disabled'],
emits: ['click'],
template: `
<button type="button" :disabled="disabled" @click="$emit('click', $event)">
<slot />
</button>
`
}
}))
vi.mock('primevue/message', () => ({
default: {
template: '<section data-testid="extension-message"><slot /></section>'
}
}))
vi.mock('primevue/selectbutton', () => ({
default: {
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template: `
<div data-testid="extension-filter">
<button
v-for="option in options"
:key="option.value"
type="button"
@click="$emit('update:modelValue', option.value)"
>
{{ option.label }}
</button>
</div>
`
}
}))
vi.mock('primevue/datatable', () => ({
default: {
props: ['value', 'selection'],
emits: ['update:selection'],
template: `
<section data-testid="extension-table">
<button
type="button"
data-testid="select-visible"
@click="$emit('update:selection', value)"
>
select
</button>
<div v-for="ext in value" :key="ext.name" data-testid="extension-row">
{{ ext.name }}
</div>
<slot />
</section>
`
}
}))
vi.mock('primevue/column', () => ({
default: {
template: '<div><slot name="header" /><slot /></div>'
}
}))
vi.mock('primevue/contextmenu', () => ({
default: {
props: ['model'],
methods: {
show: vi.fn()
},
template: `
<div data-testid="extension-menu">
<button
v-for="item in model.filter((entry) => !entry.separator)"
:key="item.label"
type="button"
:disabled="item.disabled"
@click="item.command?.()"
>
{{ item.label }}
</button>
</div>
`
}
}))
vi.mock('primevue/tag', () => ({
default: {
props: ['value'],
template: '<span>{{ value }}</span>'
}
}))
vi.mock('primevue/toggleswitch', () => ({
default: {
props: ['modelValue', 'disabled'],
emits: ['update:modelValue', 'change'],
template: `
<button
type="button"
:disabled="disabled"
data-testid="extension-toggle"
@click="$emit('update:modelValue', !modelValue); $emit('change')"
>
{{ String(modelValue) }}
</button>
`
}
}))
function renderPanel() {
return render(ExtensionPanel, {
global: {
config: {
globalProperties: fromAny({
$t: (key: string, params?: Record<string, string>) =>
params ? `${key}:${params.subject}` : key
})
}
}
})
}
describe('ExtensionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingStore.set.mockResolvedValue(undefined)
mockExtensionState.store.extensions = [
{ name: 'core.color' },
{ name: 'custom.pack' },
{ name: 'readonly.pack' }
]
mockExtensionState.store.inactiveDisabledExtensionNames = ['inactive.pack']
mockExtensionState.store.hasThirdPartyExtensions = true
mockExtensionState.store.enabled = new Set([
'core.color',
'custom.pack',
'readonly.pack'
])
mockExtensionState.store.core = new Set(['core.color'])
mockExtensionState.store.readOnly = new Set(['readonly.pack'])
})
it('filters extensions by all, core, and custom categories', async () => {
renderPanel()
expect(screen.getByTestId('extension-table')).toHaveTextContent(
'core.color'
)
expect(screen.getByTestId('extension-table')).toHaveTextContent(
'custom.pack'
)
await userEvent.click(screen.getByRole('button', { name: 'g.core' }))
expect(screen.getByTestId('extension-table')).toHaveTextContent(
'core.color'
)
expect(screen.getByTestId('extension-table')).not.toHaveTextContent(
'custom.pack'
)
await userEvent.click(screen.getByRole('button', { name: 'g.custom' }))
expect(screen.getByTestId('extension-table')).not.toHaveTextContent(
'core.color'
)
expect(screen.getByTestId('extension-table')).toHaveTextContent(
'custom.pack'
)
expect(screen.getByTestId('extension-table')).toHaveTextContent(
'readonly.pack'
)
})
it('applies selected extension commands without changing read-only rows', async () => {
renderPanel()
await userEvent.click(screen.getByTestId('select-visible'))
await userEvent.click(
screen.getByRole('button', { name: 'g.disableSelected' })
)
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
'Comfy.Extension.Disabled',
['inactive.pack', 'core.color', 'custom.pack']
)
expect(screen.getByTestId('extension-message')).toHaveTextContent(
'core.color'
)
expect(screen.getByTestId('extension-message')).toHaveTextContent(
'custom.pack'
)
expect(screen.getByTestId('extension-message')).not.toHaveTextContent(
'readonly.pack'
)
await userEvent.click(
screen.getByRole('button', { name: 'g.enableSelected' })
)
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
'Comfy.Extension.Disabled',
['inactive.pack']
)
})
it('applies bulk commands and disables third-party command when unavailable', async () => {
const { unmount } = renderPanel()
await userEvent.click(screen.getByRole('button', { name: 'g.disableAll' }))
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
'Comfy.Extension.Disabled',
['inactive.pack', 'core.color', 'custom.pack']
)
await userEvent.click(screen.getByRole('button', { name: 'g.enableAll' }))
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
'Comfy.Extension.Disabled',
['inactive.pack']
)
await userEvent.click(
screen.getByRole('button', { name: 'g.disableThirdParty' })
)
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
'Comfy.Extension.Disabled',
['inactive.pack', 'custom.pack', 'readonly.pack']
)
unmount()
mockExtensionState.store.hasThirdPartyExtensions = false
renderPanel()
expect(
screen.getByRole('button', { name: 'g.disableThirdParty' })
).toBeDisabled()
})
})

View File

@@ -0,0 +1,321 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import ServerConfigPanel from './ServerConfigPanel.vue'
import type * as Pinia from 'pinia'
const mockSettingStore = vi.hoisted(() => ({
set: vi.fn()
}))
const mockToastStore = vi.hoisted(() => ({
add: vi.fn()
}))
const mockCopy = vi.hoisted(() => vi.fn())
const mockElectronAPI = vi.hoisted(() => ({
restartApp: vi.fn()
}))
const mockServerConfigStore = vi.hoisted(() => ({
refs: null as null | {
serverConfigsByCategory: {
value: Record<
string,
Array<{
id: string
name: string
value: string
initialValue: string
tooltip?: string
}>
>
}
serverConfigValues: { value: Record<string, string> }
launchArgs: { value: string[] }
commandLineArgs: { value: string }
modifiedConfigs: {
value: Array<{
id: string
name: string
value: string
initialValue: string
}>
}
},
revertChanges: vi.fn()
}))
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal<typeof Pinia>()
return {
...actual,
storeToRefs: (store: object) => store
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, fallback?: string) => fallback ?? key
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => mockToastStore
}))
vi.mock('@/stores/serverConfigStore', async () => {
const { ref } = await import('vue')
const serverConfigsByCategory = ref({
general: [
{
id: 'listen',
name: 'Listen',
value: 'true',
initialValue: 'false',
tooltip: 'Enable listen mode'
},
{
id: 'preview',
name: 'Preview',
value: 'auto',
initialValue: 'auto'
}
]
})
const serverConfigValues = ref({ listen: 'true' })
const launchArgs = ref(['--listen'])
const commandLineArgs = ref('python main.py --listen')
const modifiedConfigs = ref([
{
id: 'listen',
name: 'Listen',
value: 'true',
initialValue: 'false'
}
])
mockServerConfigStore.refs = {
serverConfigsByCategory,
serverConfigValues,
launchArgs,
commandLineArgs,
modifiedConfigs
}
return {
useServerConfigStore: () => ({
serverConfigsByCategory,
serverConfigValues,
launchArgs,
commandLineArgs,
modifiedConfigs,
revertChanges: mockServerConfigStore.revertChanges
})
}
})
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: mockCopy
})
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => mockElectronAPI
}))
vi.mock('@/components/common/FormItem.vue', () => ({
default: {
props: ['id', 'item', 'labelClass'],
template: `
<label
:data-testid="'server-config-' + id"
:data-highlighted="String(Boolean(labelClass?.['text-highlight']))"
:title="item.tooltip"
>
{{ item.name }}={{ item.value }}
</label>
`
}
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
props: ['ariaLabel'],
emits: ['click'],
template: `
<button type="button" :aria-label="ariaLabel" @click="$emit('click')">
<slot />
</button>
`
}
}))
vi.mock('primevue/divider', () => ({
default: {
template: '<hr />'
}
}))
vi.mock('primevue/message', () => ({
default: {
template: '<section><slot name="icon" /><slot /></section>'
}
}))
function renderPanel() {
return render(ServerConfigPanel, {
global: {
config: {
globalProperties: fromAny({
$t: (key: string, fallback?: string) => fallback ?? key
})
}
}
})
}
describe('ServerConfigPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingStore.set.mockResolvedValue(undefined)
mockCopy.mockResolvedValue(undefined)
mockElectronAPI.restartApp.mockResolvedValue(undefined)
mockServerConfigStore.revertChanges.mockReset()
if (mockServerConfigStore.refs) {
mockServerConfigStore.refs.serverConfigsByCategory.value = {
general: [
{
id: 'listen',
name: 'Listen',
value: 'true',
initialValue: 'false',
tooltip: 'Enable listen mode'
},
{
id: 'preview',
name: 'Preview',
value: 'auto',
initialValue: 'auto'
}
]
}
mockServerConfigStore.refs.serverConfigValues.value = { listen: 'true' }
mockServerConfigStore.refs.launchArgs.value = ['--listen']
mockServerConfigStore.refs.commandLineArgs.value =
'python main.py --listen'
mockServerConfigStore.refs.modifiedConfigs.value = [
{
id: 'listen',
name: 'Listen',
value: 'true',
initialValue: 'false'
}
]
}
})
it('renders modified configs, translates form items, and copies command line args', async () => {
const user = userEvent.setup()
renderPanel()
expect(screen.getByText('serverConfig.modifiedConfigs')).toBeInTheDocument()
expect(screen.getByText('Listen: false → true')).toBeInTheDocument()
expect(screen.getByTestId('server-config-listen')).toHaveAttribute(
'data-highlighted',
'true'
)
expect(screen.getByTestId('server-config-listen')).toHaveAttribute(
'title',
'Enable listen mode'
)
expect(screen.getByTestId('server-config-preview')).toHaveAttribute(
'data-highlighted',
'false'
)
await user.click(screen.getByLabelText('g.copyToClipboard'))
expect(mockCopy).toHaveBeenCalledWith('python main.py --listen')
})
it('reverts, restarts, and suppresses the unmount warning after restart', async () => {
const user = userEvent.setup()
const { unmount } = renderPanel()
await user.click(
screen.getByRole('button', { name: 'serverConfig.revertChanges' })
)
expect(mockServerConfigStore.revertChanges).toHaveBeenCalledTimes(1)
await user.click(
screen.getByRole('button', { name: 'serverConfig.restart' })
)
expect(mockElectronAPI.restartApp).toHaveBeenCalledTimes(1)
unmount()
expect(mockToastStore.add).not.toHaveBeenCalled()
})
it('persists launch args and server config values through watchers', async () => {
renderPanel()
if (!mockServerConfigStore.refs) {
throw new Error('server config refs were not initialized')
}
mockServerConfigStore.refs.launchArgs.value = ['--cpu']
await nextTick()
mockServerConfigStore.refs.serverConfigValues.value = { listen: 'false' }
await nextTick()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Server.LaunchArgs',
['--cpu']
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Server.ServerConfigValues',
{ listen: 'false' }
)
})
it('warns on unmount only when modified configs remain', () => {
if (!mockServerConfigStore.refs) {
throw new Error('server config refs were not initialized')
}
mockServerConfigStore.refs.modifiedConfigs.value = []
const empty = renderPanel()
empty.unmount()
expect(mockToastStore.add).not.toHaveBeenCalled()
mockServerConfigStore.refs.modifiedConfigs.value = [
{
id: 'listen',
name: 'Listen',
value: 'true',
initialValue: 'false'
}
]
const modified = renderPanel()
modified.unmount()
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'warn',
summary: 'serverConfig.restartRequiredToastSummary',
detail: 'serverConfig.restartRequiredToastDetail',
life: 10_000
})
})
})

View File

@@ -0,0 +1,547 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import SettingDialog from './SettingDialog.vue'
interface MockSettingTreeNode {
key: string
label: string
leaf?: boolean
sortOrder?: number
data?: { id: string; name: string; sortOrder?: number }
children?: MockSettingTreeNode[]
}
const mockFetchBalance = vi.hoisted(() => vi.fn())
const mockSettingUI = vi.hoisted(() => ({
defaultPanel: undefined as string | undefined,
refs: null as null | {
settingCategories: {
value: MockSettingTreeNode[]
}
navGroups: {
value: Array<{
title: string
items: Array<{
id: string
label: string
icon?: string
badge?: string
}>
}>
}
}
}))
const mockSettingSearch = vi.hoisted(() => ({
refs: null as null | {
searchQuery: { value: string }
inSearch: { value: boolean }
searchResultsCategories: { value: Set<string> }
matchedNavItemKeys: { value: Set<string> }
results: {
value: Array<{
label: string
category?: string
settings: Array<{ id: string; name: string; sortOrder?: number }>
}>
}
},
handleSearch: vi.fn()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchBalance: mockFetchBalance
})
}))
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
useSearchQueryTracking: vi.fn()
}))
vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
default: {
template: `
<section data-testid="settings-dialog">
<header data-testid="left-title"><slot name="leftPanelHeaderTitle" /></header>
<aside data-testid="left-panel"><slot name="leftPanel" /></aside>
<div data-testid="header"><slot name="header" /></div>
<div data-testid="header-actions"><slot name="header-right-area" /></div>
<main data-testid="content"><slot name="content" /></main>
</section>
`
}
}))
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
props: ['modelValue', 'placeholder', 'autofocus'],
emits: ['update:modelValue', 'search'],
template: `
<input
data-testid="settings-search"
:value="modelValue"
:placeholder="placeholder"
:data-autofocus="String(autofocus)"
@input="$emit('update:modelValue', $event.target.value)"
@change="$emit('search', $event.target.value)"
/>
`
}
}))
vi.mock('@/components/widget/nav/NavTitle.vue', () => ({
default: {
props: ['title'],
template: '<h3>{{ title }}</h3>'
}
}))
vi.mock('@/components/widget/nav/NavItem.vue', () => ({
default: {
props: ['icon', 'badge', 'active'],
emits: ['click'],
template: `
<button
type="button"
:data-nav-id="$attrs['data-nav-id']"
:data-active="String(active)"
@click="$emit('click')"
>
<slot />
</button>
`
}
}))
vi.mock('@/components/dialog/content/setting/CurrentUserMessage.vue', () => ({
default: {
template: '<p data-testid="current-user-message">current user</p>'
}
}))
vi.mock('@/platform/settings/components/ColorPaletteMessage.vue', () => ({
default: {
template: '<p data-testid="color-palette-message">palette</p>'
}
}))
vi.mock('@/platform/settings/components/SettingsPanel.vue', () => ({
default: {
props: ['settingGroups'],
template: `
<div data-testid="settings-panel">
<section v-for="group in settingGroups" :key="group.label">
<h4>{{ group.label }}</h4>
<span v-for="setting in group.settings" :key="setting.id">
{{ setting.id }}
</span>
</section>
</div>
`
}
}))
vi.mock('@/platform/settings/composables/useSettingUI', async () => {
const { computed, defineComponent, h, ref } = await import('vue')
const settingCategories = ref([
{
key: 'Comfy',
label: 'Comfy',
children: [
{
key: 'General',
label: 'General',
children: [
{
key: 'Comfy.High',
label: 'High',
leaf: true,
data: { id: 'Comfy.High', name: 'High', sortOrder: 30 }
},
{
key: 'Comfy.Low',
label: 'Low',
leaf: true,
data: { id: 'Comfy.Low', name: 'Low', sortOrder: 10 }
}
]
},
{
key: 'Advanced',
label: 'Advanced',
children: [
{
key: 'Comfy.Advanced',
label: 'Advanced',
leaf: true,
data: { id: 'Comfy.Advanced', name: 'Advanced' }
}
]
}
]
},
{
key: 'Appearance',
label: 'Appearance',
children: [
{
key: 'Palette',
label: 'Palette',
children: [
{
key: 'Appearance.Palette',
label: 'Palette',
leaf: true,
data: {
id: 'Appearance.Palette',
name: 'Palette',
sortOrder: 20
}
}
]
}
]
}
])
const navGroups = ref([
{
title: 'Core',
items: [
{ id: 'Comfy', label: 'Comfy', icon: 'settings' },
{ id: 'Appearance', label: 'Appearance', icon: 'palette' },
{ id: 'keybinding', label: 'Keybinding', icon: 'keyboard' },
{ id: 'credits', label: 'Credits', icon: 'coins' }
]
}
])
const keybindingPanel = {
node: { key: 'keybinding', label: 'Keybinding', children: [] },
component: defineComponent({
name: 'MockKeybindingPanel',
setup: () => () => h('div', { 'data-testid': 'keybinding-panel' }, 'keys')
})
}
mockSettingUI.refs = {
settingCategories,
navGroups
}
return {
useSettingUI: vi.fn((defaultPanel?: string) => ({
defaultCategory: computed(
() =>
settingCategories.value.find((c) => c.key === defaultPanel) ??
settingCategories.value[0]
),
settingCategories,
navGroups,
findCategoryByKey: (key: string) =>
settingCategories.value.find((c) => c.key === key) ?? null,
findPanelByKey: (key: string) =>
key === 'keybinding' ? keybindingPanel : null
}))
}
})
vi.mock('@/platform/settings/composables/useSettingSearch', async () => {
const { computed, ref } = await import('vue')
const searchQuery = ref('')
const inSearch = ref(false)
const searchResultsCategories = ref(new Set<string>())
const matchedNavItemKeys = ref(new Set<string>())
const results = ref([
{
label: 'Search Group',
category: 'Comfy',
settings: [{ id: 'Comfy.SearchResult', name: 'Search Result' }]
}
])
mockSettingSearch.refs = {
searchQuery,
inSearch,
searchResultsCategories,
matchedNavItemKeys,
results
}
mockSettingSearch.handleSearch.mockImplementation(
(query: string, navItems: Array<{ key: string; label: string }> = []) => {
searchQuery.value = query
inSearch.value = query.length > 0
searchResultsCategories.value = query.includes('appearance')
? new Set(['Appearance'])
: new Set()
matchedNavItemKeys.value = new Set(
navItems
.filter((item) => item.label.toLowerCase().includes(query))
.map((item) => item.key)
)
}
)
return {
useSettingSearch: vi.fn(() => ({
searchQuery,
inSearch,
searchResultsCategories: computed(() => searchResultsCategories.value),
matchedNavItemKeys: computed(() => matchedNavItemKeys.value),
handleSearch: mockSettingSearch.handleSearch,
getSearchResults: vi.fn(() => results.value)
}))
}
})
function renderDialog(
props: Partial<InstanceType<typeof SettingDialog>['$props']> = {}
) {
return render(SettingDialog, {
props: {
onClose: vi.fn(),
...props
},
global: {
config: {
globalProperties: fromAny({
$t: (key: string, params?: Record<string, string>) =>
params ? `${key}:${params.panel}` : key
})
}
}
})
}
describe('SettingDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchBalance.mockReset()
if (mockSettingSearch.refs) {
mockSettingSearch.refs.searchQuery.value = ''
mockSettingSearch.refs.inSearch.value = false
mockSettingSearch.refs.searchResultsCategories.value = new Set()
mockSettingSearch.refs.matchedNavItemKeys.value = new Set()
}
})
it('renders the default category panel with sorted groups and settings', () => {
renderDialog()
expect(screen.getByTestId('current-user-message')).toBeInTheDocument()
expect(screen.getByTestId('settings-panel')).toHaveTextContent('General')
expect(screen.getByTestId('settings-panel')).toHaveTextContent('Advanced')
expect(screen.getByTestId('settings-panel').textContent).toMatch(
/Comfy\.High.*Comfy\.Low/
)
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
'data-active',
'true'
)
})
it('switches category from the nav and fetches credits balance for credits', async () => {
const user = userEvent.setup()
renderDialog()
await user.click(screen.getByRole('button', { name: 'Appearance' }))
expect(screen.getByTestId('color-palette-message')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveAttribute(
'data-active',
'true'
)
await user.click(screen.getByRole('button', { name: 'Credits' }))
await nextTick()
expect(mockFetchBalance).toHaveBeenCalledTimes(1)
})
it('renders panel header slots and disables search autofocus for keybindings', async () => {
const user = userEvent.setup()
renderDialog()
await user.click(screen.getByRole('button', { name: 'Keybinding' }))
await nextTick()
expect(screen.getByTestId('keybinding-panel')).toBeInTheDocument()
expect(screen.getByTestId('header')).not.toBeEmptyDOMElement()
expect(screen.getByTestId('header-actions')).not.toBeEmptyDOMElement()
expect(screen.getByTestId('settings-search')).toHaveAttribute(
'data-autofocus',
'false'
)
})
it('renders search results and activates the first matching nav item', async () => {
const user = userEvent.setup()
renderDialog()
const input = screen.getByTestId('settings-search')
await user.type(input, 'appearance')
await user.tab()
await nextTick()
expect(mockSettingSearch.handleSearch).toHaveBeenCalledWith(
'appearance',
expect.arrayContaining([{ key: 'Appearance', label: 'Appearance' }])
)
expect(screen.getByTestId('settings-panel')).toHaveTextContent(
'Comfy.SearchResult'
)
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveAttribute(
'data-active',
'true'
)
})
it('keeps search mode active when no nav item or category matches', async () => {
const user = userEvent.setup()
renderDialog()
const input = screen.getByTestId('settings-search')
await user.type(input, 'unmatched')
await user.tab()
await nextTick()
expect(screen.getByTestId('settings-panel')).toHaveTextContent(
'Comfy.SearchResult'
)
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
'data-active',
'false'
)
})
it('restores the default category after clearing search', async () => {
const user = userEvent.setup()
renderDialog()
const input = screen.getByTestId('settings-search')
await user.type(input, 'unmatched')
await user.tab()
await nextTick()
await user.clear(input)
await user.tab()
await nextTick()
expect(screen.getByTestId('current-user-message')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
'data-active',
'true'
)
})
it('sorts groups by label when group sort order ties', async () => {
const refs = mockSettingUI.refs
if (!refs) throw new Error('Expected setting UI refs')
const originalCategories = refs.settingCategories.value
const originalNavGroups = refs.navGroups.value
refs.settingCategories.value = [
...originalCategories,
{
key: 'Tie',
label: 'Tie',
children: [
{
key: 'Beta',
label: 'Beta',
children: [
{
key: 'Tie.Beta',
label: 'Beta',
leaf: true,
data: { id: 'Tie.Beta', name: 'Beta', sortOrder: 5 }
}
]
},
{
key: 'Alpha',
label: 'Alpha',
children: [
{
key: 'Tie.Alpha',
label: 'Alpha',
leaf: true,
data: { id: 'Tie.Alpha', name: 'Alpha', sortOrder: 5 }
},
{
key: 'Tie.NoSort',
label: 'NoSort',
leaf: true,
data: { id: 'Tie.NoSort', name: 'NoSort' }
}
]
}
]
}
]
refs.navGroups.value = [
{
title: 'Core',
items: [
...originalNavGroups[0].items,
{ id: 'Tie', label: 'Tie', icon: 'settings' }
]
}
]
try {
renderDialog()
await userEvent.click(screen.getByRole('button', { name: 'Tie' }))
await nextTick()
expect(screen.getByTestId('settings-panel').textContent).toMatch(
/Alpha.*Beta/
)
expect(screen.getByTestId('settings-panel').textContent).toMatch(
/Tie\.Alpha.*Tie\.NoSort/
)
} finally {
refs.settingCategories.value = originalCategories
refs.navGroups.value = originalNavGroups
}
})
it('scrolls to a target setting and removes its highlight after animation', async () => {
const target = document.createElement('div')
target.dataset.settingId = 'Comfy.Target'
const scrollIntoView = vi.fn()
target.scrollIntoView = scrollIntoView
document.body.appendChild(target)
try {
renderDialog({ scrollToSettingId: 'Comfy.Target' })
await nextTick()
await nextTick()
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center'
})
expect(target.classList.contains('setting-highlight')).toBe(true)
target.dispatchEvent(new Event('animationend'))
expect(target.classList.contains('setting-highlight')).toBe(false)
} finally {
target.remove()
}
})
})

View File

@@ -0,0 +1,199 @@
import { nextTick, reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import {
CanvasPointer,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
type SettingValue = boolean | number | string
// The real canvasStore exposes `canvas` via a shallowRef, so the mock must be
// reactive for the composable's watchEffects to re-run when the canvas mounts
// after setup. `vi.hoisted` runs before imports, hence the dynamic import.
const { canvasStore, settings } = await vi.hoisted(async () => {
const { reactive } = await import('vue')
return {
canvasStore: reactive({
canvas: undefined as
| undefined
| {
show_info?: SettingValue
zoom_speed?: SettingValue
auto_pan_speed?: SettingValue
links_render_mode?: SettingValue
min_font_size_for_lod?: SettingValue
linkMarkerShape?: SettingValue
maximumFps?: SettingValue
dragZoomEnabled?: SettingValue
liveSelection?: SettingValue
groupSelectChildren?: SettingValue
draw: ReturnType<typeof vi.fn>
setDirty: ReturnType<typeof vi.fn>
}
}),
settings: {
current: {} as Record<string, SettingValue>
}
}
})
vi.mock('@/lib/litegraph/src/litegraph', () => {
class MockCanvasPointer {
static doubleClickTime = 0
static bufferTime = 0
static maxClickDrift = 0
}
class MockLGraphNode {
static keepAllLinksOnBypass = false
}
return {
CanvasPointer: MockCanvasPointer,
LGraphNode: MockLGraphNode,
LiteGraph: {
Reroute: {},
snaps_for_comfy: false,
snap_highlights_node: false,
middle_click_slot_add_default_node: false,
CANVAS_GRID_SIZE: 0,
alwaysSnapToGrid: false,
context_menu_scaling: 1,
canvasNavigationMode: 'legacy',
macTrackpadGestures: false,
leftMouseClickBehavior: 'select',
mouseWheelScroll: 'zoom',
saveViewportWithGraph: false
}
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settings.current[key]
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasStore
}))
function makeCanvas() {
return {
draw: vi.fn(),
setDirty: vi.fn()
}
}
beforeEach(() => {
settings.current = reactive({
'Comfy.Graph.CanvasInfo': true,
'Comfy.Graph.ZoomSpeed': 1.25,
'Comfy.Graph.AutoPanSpeed': 0.75,
'Comfy.Node.AutoSnapLinkToSlot': true,
'Comfy.Node.SnapHighlightsNode': true,
'Comfy.Node.BypassAllLinksOnDelete': true,
'Comfy.Node.MiddleClickRerouteNode': true,
'Comfy.LinkRenderMode': 2,
'LiteGraph.Canvas.MinFontSizeForLOD': 9,
'Comfy.Graph.LinkMarkers': 'arrow',
'LiteGraph.Canvas.MaximumFps': 42,
'Comfy.Graph.CtrlShiftZoom': true,
'Comfy.Graph.LiveSelection': true,
'Comfy.Pointer.DoubleClickTime': 250,
'Comfy.Pointer.ClickBufferTime': 80,
'Comfy.Pointer.ClickDrift': 4,
'Comfy.SnapToGrid.GridSize': 16,
'pysssss.SnapToGrid': true,
'LiteGraph.ContextMenu.Scaling': 1.5,
'LiteGraph.Reroute.SplineOffset': 32,
'Comfy.Canvas.NavigationMode': 'standard',
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
'Comfy.Canvas.MouseWheelScroll': 'panning',
'Comfy.EnableWorkflowViewRestore': true,
'LiteGraph.Group.SelectChildrenOnClick': true
})
canvasStore.canvas = reactive(makeCanvas())
})
describe('useLitegraphSettings', () => {
it('applies canvas settings and marks affected layers dirty', () => {
useLitegraphSettings()
expect(canvasStore.canvas?.show_info).toBe(true)
expect(canvasStore.canvas?.zoom_speed).toBe(1.25)
expect(canvasStore.canvas?.auto_pan_speed).toBe(0.75)
expect(canvasStore.canvas?.links_render_mode).toBe(2)
expect(canvasStore.canvas?.min_font_size_for_lod).toBe(9)
expect(canvasStore.canvas?.linkMarkerShape).toBe('arrow')
expect(canvasStore.canvas?.maximumFps).toBe(42)
expect(canvasStore.canvas?.dragZoomEnabled).toBe(true)
expect(canvasStore.canvas?.liveSelection).toBe(true)
expect(canvasStore.canvas?.groupSelectChildren).toBe(true)
expect(canvasStore.canvas?.draw).toHaveBeenCalledWith(false, true)
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(false, true)
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(true, true)
})
it('applies global LiteGraph and pointer settings', () => {
useLitegraphSettings()
expect(LiteGraph.snaps_for_comfy).toBe(true)
expect(LiteGraph.snap_highlights_node).toBe(true)
expect(LGraphNode.keepAllLinksOnBypass).toBe(true)
expect(LiteGraph.middle_click_slot_add_default_node).toBe(true)
expect(CanvasPointer.doubleClickTime).toBe(250)
expect(CanvasPointer.bufferTime).toBe(80)
expect(CanvasPointer.maxClickDrift).toBe(4)
expect(LiteGraph.CANVAS_GRID_SIZE).toBe(16)
expect(LiteGraph.alwaysSnapToGrid).toBe(true)
expect(LiteGraph.context_menu_scaling).toBe(1.5)
expect(LiteGraph.Reroute.maxSplineOffset).toBe(32)
expect(LiteGraph.canvasNavigationMode).toBe('standard')
expect(LiteGraph.macTrackpadGestures).toBe(true)
expect(LiteGraph.leftMouseClickBehavior).toBe('panning')
expect(LiteGraph.mouseWheelScroll).toBe('panning')
expect(LiteGraph.saveViewportWithGraph).toBe(true)
})
it('responds when reactive settings change', async () => {
useLitegraphSettings()
settings.current['Comfy.Graph.CanvasInfo'] = false
settings.current['Comfy.Canvas.NavigationMode'] = 'custom'
settings.current['LiteGraph.Group.SelectChildrenOnClick'] = false
await nextTick()
expect(canvasStore.canvas?.show_info).toBe(false)
expect(canvasStore.canvas?.groupSelectChildren).toBe(false)
expect(LiteGraph.canvasNavigationMode).toBe('custom')
expect(LiteGraph.macTrackpadGestures).toBe(false)
})
it('updates global settings when the canvas is not mounted yet', () => {
canvasStore.canvas = undefined
useLitegraphSettings()
expect(LiteGraph.snaps_for_comfy).toBe(true)
expect(CanvasPointer.doubleClickTime).toBe(250)
})
it('applies canvas settings once the canvas mounts after setup', async () => {
canvasStore.canvas = undefined
useLitegraphSettings()
canvasStore.canvas = reactive(makeCanvas())
await nextTick()
expect(canvasStore.canvas?.show_info).toBe(true)
expect(canvasStore.canvas?.zoom_speed).toBe(1.25)
expect(canvasStore.canvas?.links_render_mode).toBe(2)
expect(canvasStore.canvas?.draw).toHaveBeenCalledWith(false, true)
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(false, true)
})
})

View File

@@ -1,7 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
getSettingInfo,
@@ -11,31 +10,47 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingUI } from './useSettingUI'
const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({
auth: { isLoggedIn: { value: false } },
billing: { isActiveSubscription: { value: false } },
dist: { isCloud: false, isDesktop: false },
featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false },
vueFlags: { shouldRenderVueNodes: { value: false } }
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: ref(false) })
useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isActiveSubscription: ref(false) })
useBillingContext: () => ({
isActiveSubscription: billing.isActiveSubscription
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
flags: featureFlags
})
}))
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
useVueFeatureFlags: () => ({
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
})
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false
get isCloud() {
return dist.isCloud
},
get isDesktop() {
return dist.isDesktop
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -49,6 +64,7 @@ interface MockSettingParams {
type: string
defaultValue: unknown
category?: string[]
hideInVueNodes?: boolean
}
describe('useSettingUI', () => {
@@ -72,13 +88,23 @@ describe('useSettingUI', () => {
defaultValue: 'dark'
}
}
let settingsById: Record<string, MockSettingParams>
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
auth.isLoggedIn.value = false
billing.isActiveSubscription.value = false
dist.isCloud = false
dist.isDesktop = false
featureFlags.teamWorkspacesEnabled = false
featureFlags.userSecretsEnabled = false
vueFlags.shouldRenderVueNodes.value = false
Object.assign(window, { __CONFIG__: {} })
settingsById = mockSettings
vi.mocked(useSettingStore).mockReturnValue({
settingsById: mockSettings
settingsById
} as ReturnType<typeof useSettingStore>)
vi.mocked(getSettingInfo).mockImplementation((setting) => {
@@ -107,9 +133,9 @@ describe('useSettingUI', () => {
undefined,
'Comfy.Locale'
)
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
expect(comfyCategory).toBeDefined()
expect(defaultCategory.value).toBe(comfyCategory)
expect(defaultCategory.value).toBe(
findCategory(settingCategories.value, 'Comfy')
)
})
it('resolves different category from scrollToSettingId', () => {
@@ -121,7 +147,6 @@ describe('useSettingUI', () => {
settingCategories.value,
'Appearance'
)
expect(appearanceCategory).toBeDefined()
expect(defaultCategory.value).toBe(appearanceCategory)
})
@@ -137,4 +162,192 @@ describe('useSettingUI', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
it('falls back when defaultPanel is not in the menu', () => {
const missingPanel = 'missing' as unknown as Parameters<
typeof useSettingUI
>[0]
const { defaultCategory, settingCategories } = useSettingUI(missingPanel)
expect(defaultCategory.value).toBe(settingCategories.value[0])
})
it('moves floating settings into Other and hides Vue-node-only settings', () => {
settingsById = {
Floating: {
id: 'Floating',
name: 'Floating',
type: 'boolean',
defaultValue: false
},
'Hidden.Setting': {
id: 'Hidden.Setting',
name: 'Hidden',
type: 'hidden',
defaultValue: false
},
'Vue.Hidden': {
id: 'Vue.Hidden',
name: 'Vue Hidden',
type: 'boolean',
defaultValue: false,
hideInVueNodes: true
}
}
vi.mocked(useSettingStore).mockReturnValue({
settingsById
} as ReturnType<typeof useSettingStore>)
vueFlags.shouldRenderVueNodes.value = true
const { settingCategories } = useSettingUI()
expect(settingCategories.value.map((category) => category.label)).toEqual([
'Other'
])
expect(
settingCategories.value[0].children?.map((node) => node.key)
).toEqual(['root/Floating'])
})
it('adds gated cloud, desktop, workspace, and secrets panels', () => {
auth.isLoggedIn.value = true
billing.isActiveSubscription.value = true
dist.isCloud = true
dist.isDesktop = true
featureFlags.teamWorkspacesEnabled = true
featureFlags.userSecretsEnabled = true
Object.assign(window, { __CONFIG__: { subscription_required: true } })
const { findCategoryByKey, findPanelByKey, navGroups, panels } =
useSettingUI()
expect(panels.value.map((panel) => panel.node.key)).toEqual([
'about',
'credits',
'user',
'workspace',
'keybinding',
'extension',
'server-config',
'subscription',
'secrets'
])
expect(navGroups.value.map((group) => group.title)).toEqual([
'Workspace',
'General'
])
expect(findCategoryByKey('secrets')?.key).toBe('secrets')
expect(findCategoryByKey('missing')).toBeNull()
expect(findPanelByKey('subscription')?.node.key).toBe('subscription')
expect(findPanelByKey('missing')).toBeNull()
})
it('builds the legacy account menu from auth and subscription gates', () => {
auth.isLoggedIn.value = true
billing.isActiveSubscription.value = true
dist.isCloud = true
featureFlags.userSecretsEnabled = true
Object.assign(window, { __CONFIG__: { subscription_required: true } })
const { navGroups, panels } = useSettingUI()
expect(panels.value.map((panel) => panel.node.key)).toEqual([
'about',
'credits',
'user',
'keybinding',
'extension',
'subscription',
'secrets'
])
expect(navGroups.value[0]).toEqual({
title: 'Account',
items: [
{
id: 'user',
label: 'User',
icon: 'icon-[lucide--user]'
},
{
id: 'subscription',
label: 'PlanCredits',
icon: 'icon-[lucide--credit-card]'
},
{
id: 'secrets',
label: 'Secrets',
icon: 'icon-[lucide--key-round]'
}
]
})
})
it('includes credits in legacy account settings when login is not subscription-gated', () => {
auth.isLoggedIn.value = true
dist.isCloud = true
const { navGroups } = useSettingUI()
expect(navGroups.value[0].items.map((item) => item.id)).toEqual([
'user',
'credits'
])
})
it('builds workspace menus without optional children when gates are closed', () => {
dist.isCloud = true
featureFlags.teamWorkspacesEnabled = true
const { navGroups, panels } = useSettingUI()
expect(panels.value.map((panel) => panel.node.key)).toEqual([
'about',
'credits',
'user',
'keybinding',
'extension'
])
expect(navGroups.value.map((group) => group.title)).toEqual([
'Workspace',
'General'
])
expect(navGroups.value[0].items).toEqual([])
})
it('uses label and fallback icons for custom categories', () => {
settingsById = {
'Acme.Tools.Toggle': {
id: 'Acme.Tools.Toggle',
name: 'Toggle',
type: 'boolean',
defaultValue: false,
category: ['Acme Tools', 'Toggles']
},
PlanSetting: {
id: 'PlanSetting',
name: 'Plan Setting',
type: 'boolean',
defaultValue: false,
category: ['PlanCredits', 'Credits']
}
}
vi.mocked(useSettingStore).mockReturnValue({
settingsById
} as ReturnType<typeof useSettingStore>)
const { navGroups } = useSettingUI()
const settingsItems = navGroups.value[1].items
expect(settingsItems).toEqual([
{
id: 'root/Acme Tools',
label: 'Acme Tools',
icon: 'icon-[lucide--plug]'
},
{
id: 'root/PlanCredits',
label: 'PlanCredits',
icon: 'icon-[lucide--credit-card]'
}
])
})
})

View File

@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import type { SettingParams } from '@/platform/settings/types'
import type { Keybinding } from '@/platform/keybindings/types'
const mockSettingStore = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
setMany: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false,
isNightly: false
}))
vi.mock('@/locales/localeConfig', () => ({
getDefaultLocale: () => 'en',
SUPPORTED_LOCALE_OPTIONS: [{ value: 'en', text: 'English' }]
}))
function setting<T = unknown>(id: string): SettingParams<T> {
const result = CORE_SETTINGS.find((item) => item.id === id)
if (!result) throw new Error(`Missing setting ${id}`)
return result as SettingParams<T>
}
describe('CORE_SETTINGS', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.className = ''
document.body.innerHTML = ''
})
it('uses compact sidebar size below the wide breakpoint', () => {
vi.stubGlobal('innerWidth', 1200)
const defaultValue = setting('Comfy.Sidebar.Size').defaultValue
expect(typeof defaultValue).toBe('function')
expect((defaultValue as () => string)()).toBe('small')
})
it('uses normal sidebar size above the wide breakpoint', () => {
vi.stubGlobal('innerWidth', 1600)
const defaultValue = setting('Comfy.Sidebar.Size').defaultValue
expect((defaultValue as () => string)()).toBe('normal')
})
it('updates dependent canvas settings when navigation mode changes', async () => {
const navigation = setting<string>('Comfy.Canvas.NavigationMode')
await navigation.onChange?.('standard', 'legacy')
expect(mockSettingStore.setMany).toHaveBeenLastCalledWith({
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
'Comfy.Canvas.MouseWheelScroll': 'panning'
})
await navigation.onChange?.('legacy', 'standard')
expect(mockSettingStore.setMany).toHaveBeenLastCalledWith({
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
'Comfy.Canvas.MouseWheelScroll': 'zoom'
})
})
it('does not update dependent canvas settings on initial navigation setup', async () => {
await setting<string>('Comfy.Canvas.NavigationMode').onChange?.('standard')
expect(mockSettingStore.setMany).not.toHaveBeenCalled()
})
it('keeps preset navigation mode when left-click behavior still matches it', async () => {
mockSettingStore.get.mockReturnValue('standard')
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
'select'
)
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
it('marks navigation mode custom when left-click behavior diverges from the preset', async () => {
mockSettingStore.get.mockReturnValue('standard')
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
'panning'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode',
'custom'
)
})
it('does not rewrite custom navigation mode from left-click behavior', async () => {
mockSettingStore.get.mockReturnValue('custom')
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
'select'
)
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
it('keeps preset navigation mode when wheel behavior still matches it', async () => {
mockSettingStore.get.mockReturnValue('legacy')
await setting<string>('Comfy.Canvas.MouseWheelScroll').onChange?.('zoom')
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
it('marks navigation mode custom when wheel behavior diverges from the preset', async () => {
mockSettingStore.get.mockReturnValue('legacy')
await setting<string>('Comfy.Canvas.MouseWheelScroll').onChange?.('panning')
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode',
'custom'
)
})
it('toggles the dev-mode API save button when present', () => {
const button = document.createElement('button')
button.id = 'comfy-dev-save-api-button'
document.body.append(button)
const devMode = setting<boolean>('Comfy.DevMode')
devMode.onChange?.(true)
expect(button.style.display).toBe('flex')
devMode.onChange?.(false)
expect(button.style.display).toBe('none')
})
it('ignores the dev-mode button handler when the element is absent', () => {
expect(() =>
setting<boolean>('Comfy.DevMode').onChange?.(true)
).not.toThrow()
})
it('toggles the disabled animations body class', () => {
const animations = setting<boolean>('Comfy.Appearance.DisableAnimations')
animations.onChange?.(true)
expect(document.body.classList.contains('disable-animations')).toBe(true)
animations.onChange?.(false)
expect(document.body.classList.contains('disable-animations')).toBe(false)
})
it('migrates deprecated menu and workflow tab values', () => {
expect(
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Floating')
).toBe('Top')
expect(
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Bottom')
).toBe('Top')
expect(
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Top')
).toBe('Top')
expect(
setting<string>(
'Comfy.Workflow.WorkflowTabsPosition'
).migrateDeprecatedValue?.('Topbar (2nd-row)')
).toBe('Topbar')
})
it('migrates graph-canvas keybinding target selectors', () => {
const bindings = [
{
combo: { key: 'a' },
commandId: 'test.command',
targetSelector: '#graph-canvas'
},
{
combo: { key: 'b' },
commandId: 'other.command',
targetSelector: '#other'
}
] as unknown as Keybinding[]
const migrated =
setting<Keybinding[]>(
'Comfy.Keybinding.UnsetBindings'
).migrateDeprecatedValue?.(bindings) ?? []
expect(migrated[0].targetElementId).toBe('graph-canvas-container')
expect(migrated[1].targetElementId).toBeUndefined()
})
})

View File

@@ -30,6 +30,39 @@ 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()

View File

@@ -1,6 +1,7 @@
import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -99,8 +100,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
}
trackAddApiCreditButtonClicked(): void {
this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.())
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.dispatch((provider) =>
provider.trackAddApiCreditButtonClicked?.(metadata)
)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {

View File

@@ -313,6 +313,42 @@ 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()

View File

@@ -10,7 +10,9 @@ import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AddCreditsClickMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -350,8 +352,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName, metadata)
}
trackAddApiCreditButtonClicked(): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
}
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
this.trackEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata)
}
trackMonthlySubscriptionSucceeded(

View File

@@ -115,6 +115,17 @@ 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

View File

@@ -10,6 +10,7 @@ import {
import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -126,8 +127,8 @@ export class HostTelemetrySink implements TelemetryProvider {
this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
}
trackAddApiCreditButtonClicked(): void {
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {

View File

@@ -12,12 +12,29 @@
* 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
*/
@@ -426,16 +443,23 @@ export interface CheckoutAttributionMetadata {
export interface SubscriptionMetadata {
current_tier?: string
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
}
export interface AddCreditsClickMetadata {
source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel'
}
export interface BeginCheckoutMetadata
extends Record<string, unknown>, CheckoutAttributionMetadata {
user_id: string
tier: TierKey
tier: SubscriptionCheckoutTier
cycle: BillingCycle
checkout_type: 'new' | 'change'
checkout_type: SubscriptionCheckoutType
checkout_attempt_id?: string
billing_op_id?: string
previous_tier?: TierKey
payment_intent_source?: PaymentIntentSource
}
interface EcommerceItemMetadata {
@@ -457,8 +481,9 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
checkout_attempt_id: string
tier: TierKey
cycle: BillingCycle
checkout_type: 'new' | 'change'
checkout_type: SubscriptionCheckoutType
previous_tier?: TierKey
payment_intent_source?: PaymentIntentSource
value: number
currency: string
ecommerce: EcommerceMetadata
@@ -489,7 +514,7 @@ export interface TelemetryProvider {
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionCancelled?(): void
trackAddApiCreditButtonClicked?(): void
trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void

View File

@@ -321,7 +321,7 @@ const handleOpenWorkspaceSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
emit('close')
}
@@ -336,13 +336,12 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
emit('close')
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
dialogService.showTopUpCreditsDialog()
emit('close')
}

View File

@@ -391,12 +391,13 @@ const showZeroState = computed(
)
function handleSubscribeWorkspace() {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'settings_billing_panel' })
}
function handleUpgrade() {
if (isFreeTierPlan.value) showPricingTable()
else showSubscriptionDialog()
if (isFreeTierPlan.value)
showPricingTable({ reason: 'settings_billing_panel' })
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
}
function handleViewMoreDetails() {

View File

@@ -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 { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
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?: SubscriptionDialogReason
reason?: PaymentIntentSource
initialPlanMode?: 'personal' | 'team'
}>()
@@ -152,7 +152,7 @@ const {
handleConfirmTransition,
handleTeamSubscribe,
handleResubscribe
} = useSubscriptionCheckout(emit)
} = useSubscriptionCheckout(emit, reason)
// Backspace mirrors the back arrow on the confirm step, but never while an
// editable element is focused (let it delete text there).

View File

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue'
@@ -17,25 +17,10 @@ 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: () => ({
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
})
useSubscriptionCheckout: mockUseSubscriptionCheckout
}))
const i18n = createI18n({
@@ -91,7 +76,7 @@ const SuccessStub = {
function renderComponent(
props: {
onClose?: () => void
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
isPersonal?: boolean
} = {}
) {
@@ -121,6 +106,23 @@ 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
})
@@ -132,6 +134,15 @@ 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()

View File

@@ -116,7 +116,7 @@
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
import PricingTableWorkspace from './PricingTableWorkspace.vue'
@@ -130,7 +130,7 @@ const {
isPersonal = false
} = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
isPersonal?: boolean
}>()
@@ -154,7 +154,7 @@ const {
handleConfirmTransition,
handleResubscribe,
handleSuccessClose
} = useSubscriptionCheckout(emit)
} = useSubscriptionCheckout(emit, reason)
</script>
<style scoped>

View File

@@ -61,6 +61,9 @@ function onDismiss() {
function onUpgrade() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
subscriptionDialog.show({ planMode: 'team' })
subscriptionDialog.show({
planMode: 'team',
reason: 'invite_member_upsell'
})
}
</script>

View File

@@ -277,7 +277,7 @@ export function useMembersPanel() {
}
function showTeamPlans() {
subscriptionDialog.show({ planMode: 'team' })
subscriptionDialog.show({ planMode: 'team', reason: 'team_members_panel' })
}
return {

View File

@@ -1,8 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { computed, reactive } from 'vue'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { findPlanSlug } from './useSubscriptionCheckout'
@@ -75,7 +76,9 @@ const {
mockPlans,
mockResubscribe,
mockToastAdd,
mockStartOperation
mockStartOperation,
mockTrackBeginCheckout,
mockUserId
} = vi.hoisted(() => ({
mockSubscribe: vi.fn(),
mockPreviewSubscribe: vi.fn(),
@@ -84,7 +87,9 @@ const {
mockPlans: { value: [] as Plan[] },
mockResubscribe: vi.fn(),
mockToastAdd: vi.fn(),
mockStartOperation: vi.fn()
mockStartOperation: vi.fn(),
mockTrackBeginCheckout: vi.fn(),
mockUserId: { value: 'user-1' as string | null }
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
@@ -119,7 +124,14 @@ vi.mock('primevue/usetoast', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() })
useTelemetry: () => ({
trackMonthlySubscriptionSucceeded: vi.fn(),
trackBeginCheckout: mockTrackBeginCheckout
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
}))
vi.mock('vue-i18n', async (importOriginal) => {
@@ -135,10 +147,10 @@ vi.mock('vue-i18n', async (importOriginal) => {
describe('useSubscriptionCheckout', () => {
let emit: ReturnType<typeof vi.fn>
async function setup() {
async function setup(paymentIntentSource?: PaymentIntentSource) {
const { useSubscriptionCheckout } =
await import('./useSubscriptionCheckout')
return useSubscriptionCheckout(emit as never)
return useSubscriptionCheckout(emit as never, paymentIntentSource)
}
beforeEach(() => {
@@ -146,6 +158,7 @@ describe('useSubscriptionCheckout', () => {
vi.clearAllMocks()
mockPlans.value = allPlans()
mockStartOperation.mockResolvedValue({ status: 'succeeded' })
mockUserId.value = 'user-1'
emit = vi.fn()
})
@@ -459,6 +472,13 @@ 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 () => {
@@ -553,6 +573,39 @@ 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'
})
)
})
})
@@ -603,6 +656,47 @@ 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'
@@ -720,6 +814,7 @@ describe('useSubscriptionCheckout', () => {
detail: 'Payment failed'
})
)
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
})

View File

@@ -9,16 +9,26 @@ 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
@@ -45,9 +55,12 @@ export function findPlanSlug(
return plan?.slug ?? null
}
export function useSubscriptionCheckout(emit: {
(e: 'close', subscribed: boolean): void
}) {
export function useSubscriptionCheckout(
emit: {
(e: 'close', subscribed: boolean): void
},
paymentIntentSource?: PaymentIntentSource
) {
const { t } = useI18n()
const toast = useToast()
const {
@@ -68,13 +81,16 @@ export function useSubscriptionCheckout(emit: {
const isResubscribing = ref(false)
const previewData = ref<PreviewSubscribeResponse | null>(null)
const selectedTierKey = ref<CheckoutTierKey | null>(null)
const selectedTeamStop = ref<TeamPlanSelection | null>(null)
const selectedTeamCheckout = ref<SelectedTeamCheckout | null>(null)
const selectedBillingCycle = ref<BillingCycle>('yearly')
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
const isTeamCheckout = computed(() => selectedTeamStop.value !== null)
const selectedTeamStop = computed(
() => selectedTeamCheckout.value?.stop ?? null
)
const isTeamCheckout = computed(() => selectedTeamCheckout.value !== null)
const previewVariant = computed<PreviewVariant>(() => {
if (selectedTeamStop.value) {
if (selectedTeamCheckout.value) {
return previewData.value ? 'team-change' : 'team-new'
}
if (previewData.value) {
@@ -154,7 +170,10 @@ export function useSubscriptionCheckout(emit: {
billingCycle: BillingCycle
isChange?: boolean
}) {
selectedTeamStop.value = payload.stop
selectedTeamCheckout.value = {
stop: payload.stop,
checkoutType: payload.isChange ? 'change' : 'new'
}
selectedBillingCycle.value = payload.billingCycle
selectedTierKey.value = null
previewData.value = null
@@ -182,7 +201,7 @@ export function useSubscriptionCheckout(emit: {
function handleBackToPricing() {
checkoutStep.value = 'pricing'
previewData.value = null
selectedTeamStop.value = null
selectedTeamCheckout.value = null
}
function handleSuccessClose() {
@@ -190,20 +209,34 @@ export function useSubscriptionCheckout(emit: {
}
async function handleSubscription() {
if (!selectedTierKey.value) return
const tierKey = selectedTierKey.value
if (!tierKey) return
const billingCycle = selectedBillingCycle.value
const checkoutType =
previewData.value &&
previewData.value.transition_type !== 'new_subscription'
? 'change'
: 'new'
isSubscribing.value = true
try {
const planSlug = getApiPlanSlug(
selectedTierKey.value,
selectedBillingCycle.value
)
const planSlug = getApiPlanSlug(tierKey, billingCycle)
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)
@@ -269,8 +302,8 @@ export function useSubscriptionCheckout(emit: {
}
async function handleTeamSubscription() {
const stop = selectedTeamStop.value
if (!stop?.id) {
const teamCheckout = selectedTeamCheckout.value
if (!teamCheckout?.stop.id) {
toast.add({
severity: 'error',
summary: t('subscription.teamPlan.name'),
@@ -279,16 +312,28 @@ export function useSubscriptionCheckout(emit: {
return
}
const { stop, checkoutType } = teamCheckout
const billingCycle = selectedBillingCycle.value
isSubscribing.value = true
try {
const planSlug = getTeamPlanSlug(selectedBillingCycle.value)
const planSlug = getTeamPlanSlug(billingCycle)
const response = await subscribe(planSlug, {
teamCreditStopId: stop.id,
billingCycle: selectedBillingCycle.value,
billingCycle,
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)

View File

@@ -2,6 +2,7 @@ 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,
@@ -275,12 +276,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
subscriptionDialog.show()
subscriptionDialog.show({ reason: 'subscription_required' })
}
}
function showSubscriptionDialog(): void {
subscriptionDialog.show()
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
subscriptionDialog.show(options)
}
return {

View File

@@ -0,0 +1,38 @@
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 }
: {})
})
}

View File

@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import {
trackNodePrice,
usePartitionedBadges
} from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { toNodeId } from '@/types/nodeId'
import { NodeBadgeMode } from '@/types/nodeSource'
const { settings, nodeDefs, pricing, getNodeRevisionRefMock, getWidgetMock } =
vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
nodeDefs: {} as Record<string, unknown>,
pricing: {
dynamic: false,
widgets: [] as string[],
inputs: [] as string[],
groups: [] as string[]
},
getNodeRevisionRefMock: vi.fn(() => ({ value: 0 })),
getWidgetMock: vi.fn(() => ({ value: 'widget-value' }))
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: { graph: { getNodeById: () => null, rootGraph: { id: 'g1' } } }
}
}))
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({
getRelevantWidgetNames: () => pricing.widgets,
hasDynamicPricing: () => pricing.dynamic,
getInputGroupPrefixes: () => pricing.groups,
getInputNames: () => pricing.inputs,
getNodeRevisionRef: getNodeRevisionRefMock
})
}))
vi.mock('@/composables/node/usePriceBadge', () => ({
usePriceBadge: () => ({
isCreditsBadge: (b: { text?: string }) => b.text?.startsWith('$') ?? false
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (key: string) => settings[key] })
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({ nodeDefsByName: nodeDefs })
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: getWidgetMock })
}))
function nodeData(overrides: Partial<VueNodeData> = {}): VueNodeData {
return {
executing: false,
id: toNodeId(1),
mode: 0,
selected: false,
title: 'Test node',
type: 'TestNode',
apiNode: false,
badges: [],
inputs: [],
...overrides
} satisfies VueNodeData
}
function inputSlot(
name: string,
readLink: () => number | null
): INodeInputSlot {
return {
name,
type: '*',
boundingRect: [0, 0, 0, 0],
get link() {
return readLink()
},
set link(_value: number | null) {}
} as INodeInputSlot
}
function badge(text: string): LGraphBadge {
return new LGraphBadge({ text })
}
beforeEach(() => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
for (const k of Object.keys(nodeDefs)) delete nodeDefs[k]
nodeDefs['TestNode'] = { isCoreNode: false }
pricing.dynamic = false
pricing.widgets = []
pricing.inputs = []
pricing.groups = []
getNodeRevisionRefMock.mockClear()
getWidgetMock.mockClear()
})
describe('usePartitionedBadges', () => {
it('emits no core badges when every badge mode is None', () => {
const result = usePartitionedBadges(nodeData()).value
expect(result.core).toEqual([])
})
it('tracks dynamic-pricing dependencies for an api node without throwing', () => {
pricing.dynamic = true
pricing.widgets = ['seed']
pricing.inputs = ['model']
pricing.groups = ['lora']
const result = usePartitionedBadges(
nodeData({
apiNode: true,
inputs: [
inputSlot('model', () => 1),
inputSlot('lora.0', () => 2),
inputSlot('unrelated', () => null)
]
})
).value
expect(result).toHaveProperty('core')
expect(result).toHaveProperty('extension')
})
it('adds an id badge when the id mode is enabled', () => {
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
const result = usePartitionedBadges(nodeData({ id: toNodeId(7) })).value
expect(result.core).toContainEqual({ text: '#7' })
})
it('adds a lifecycle badge, trimmed of brackets', () => {
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll
nodeDefs['TestNode'] = {
isCoreNode: false,
nodeLifeCycleBadgeText: '[BETA]'
}
const result = usePartitionedBadges(nodeData()).value
expect(result.core).toContainEqual({ text: 'BETA' })
})
it('adds a source badge for non-core nodes when source mode is on', () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
nodeDefs['TestNode'] = {
isCoreNode: false,
nodeSource: { badgeText: 'my-pack' }
}
const result = usePartitionedBadges(nodeData()).value
expect(result.core).toContainEqual({ text: 'my-pack' })
})
it('partitions extension badges (skipping the first) from credits badges', () => {
const result = usePartitionedBadges(
nodeData({
badges: [badge('skipped'), badge('ext-badge'), badge('$5 per run')]
})
).value
expect(result.extension.map((badge) => badge.text)).toEqual(['ext-badge'])
expect(result.pricing).toEqual([{ required: '$5', rest: 'per run' }])
})
it('flags hasComfyBadge for a core node with source ShowAll and no pricing', () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
nodeDefs['TestNode'] = { isCoreNode: true }
const result = usePartitionedBadges(
nodeData({ badges: [badge('x')] })
).value
expect(result.hasComfyBadge).toBe(true)
})
})
describe('trackNodePrice', () => {
it('no-ops for a node without dynamic pricing', () => {
pricing.dynamic = false
trackNodePrice({ id: '1', type: 'Static', inputs: [] })
expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('1'))
expect(getWidgetMock).not.toHaveBeenCalled()
})
it('touches widget, input, and input-group pricing dependencies', () => {
pricing.dynamic = true
pricing.widgets = ['seed']
pricing.inputs = ['model']
pricing.groups = ['lora']
let modelReads = 0
let groupReads = 0
let unrelatedReads = 0
trackNodePrice({
id: '2',
type: 'Dynamic',
inputs: [
inputSlot('model', () => {
modelReads += 1
return 1
}),
inputSlot('lora.0', () => {
groupReads += 1
return 2
}),
inputSlot('unrelated', () => {
unrelatedReads += 1
return null
})
]
})
expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('2'))
expect(getWidgetMock).toHaveBeenCalled()
expect(modelReads).toBe(1)
expect(groupReads).toBe(1)
expect(unrelatedReads).toBe(0)
})
})

View File

@@ -18,7 +18,7 @@ import type {
} from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
// Lazy loaders for dialogs - components are loaded on first use
@@ -442,9 +442,9 @@ export const useDialogService = () => {
})
}
async function showSubscriptionRequiredDialog(options?: {
reason?: SubscriptionDialogReason
}) {
async function showSubscriptionRequiredDialog(
options?: SubscriptionDialogOptions
) {
if (!isCloud || !window.__CONFIG__?.subscription_required) {
return
}