Compare commits

...

9 Commits

Author SHA1 Message Date
huang47
be217f1ce7 test: assert observable state after initializeConflictDetection
The initialization smoke test only asserted resolves.not.toThrow(),
which passes even if the guarded branch does nothing. Add a companion
assertion on conflictStore.clearConflicts() to prove the analysis
actually ran and updated state.
2026-07-07 21:07:48 -07:00
huang47
d4c5d75888 test: remove axios config-echo test from comfyManagerService 2026-07-06 12:04:23 -07:00
huang47
f4347ae586 test: address review feedback on manager composable and util tests
- exercise a real non-zero page before asserting sort-change reset
- recreate mock api EventTarget per test to avoid stale listeners
- use underscore version fixture so normalizePackId path is exercised
2026-07-03 14:56:08 -07:00
huang47
b7d5b9a0f5 test: replace double casts and vi.mock(vue-i18n) in manager test suite
- usePacksSelection: drop fromAny; store now correctly types
  getInstalledPackVersion as string | undefined
- usePackInstall: remove vi.mock('vue-i18n'); update thrown message
  assertion to match real i18n translation; use fromPartial<NodePack>
  in pack() helper; remove redundant undefined-getter test case
- useManagerDisplayPacks: use fromPartial<NodePack> in pack() helper
- useConflictDetection: replace all as Partial<T> as ReturnType<...>
  double casts with fromPartial<ReturnType<...>>; remove dead
  checkComfyUIVersionChange from mock
- comfyManagerStore: remove unnecessary vi.mock('vue-i18n')
- nodeHelpUtil: drop ComfyNodeDefImpl cast from nodeDef() helper;
  parameter type is now structurally compatible without cast
2026-07-03 09:30:29 -07:00
huang47
1ba3f7c91b fix: widen getInstalledPackVersion return type and narrow nodeHelpUtil param
- getInstalledPackVersion now explicitly returns string | undefined since
  the installed packs map may not contain the queried key at runtime
- usePackInstall switches from useI18n() to module-level t from @/i18n,
  consistent with other composables in the same package
- getNodeHelpBaseUrl narrows its parameter from ComfyNodeDefImpl to the
  structural minimum { name, python_module } so tests need no cast
2026-07-03 09:29:42 -07:00
huang47
1ca2c86df1 test: cover manager composables 2026-07-02 14:39:01 -07:00
huang47
4faf1de7be refactor: hoist and reuse manager test mocks
Prep for follow-up commit adding new coverage: promote inline vi.fn()
mocks to named vi.hoisted() references, dedupe repeated fixture
helpers, and make shouldShowConflictModal/app-api listener mocks
controllable so new tests can assert on and drive them directly.
2026-07-02 14:38:18 -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
Mobeen Abdullah
9cf5c9a93f refactor(website): tidy customer story review nits (#13324)
## Summary

Small follow-up to #13289 applying two non-blocking review nits from
Alex's review.

## Changes

- **What**: drop the redundant `before:content-['']` on the
customer-story list bullet (Tailwind emits the empty `content`
automatically once another `before:` utility is present), and rename
`HEADER_OFFSET` to `HEADER_OFFSET_PX` in `ArticleNav` so the scroll
constants use consistent unit suffixes.

## Review Focus

Both changes are cosmetic with no behavior change. Confirmed in the
browser that the list bullet still renders identically (6px yellow dot)
without the explicit `content` utility.

## Notes from the #13289 review (left as-is here, open to discussion)

Three other comments from the review are intentionally not changed in
this PR; reasoning below so the decisions are on record:

- **`Category` type in `ArticleNav`**: kept the `ComponentProps<typeof
CategoryNav>` derivation. AGENTS.md says to derive component types via
`vue-component-type-helpers` rather than redefining them, so the current
form follows the styleguide. Happy to switch to a plain named type if
preferred.
- **Section ids in frontmatter vs the body `<Section>`**: kept the
`customers.content.test.ts` parity test. The short TOC labels live only
in frontmatter and Astro can't introspect the rendered MDX body to build
the nav, so the frontmatter `sections` list and the body anchor ids
can't be trivially deduplicated. A real fix would need a remark plugin
(larger, separate change). The test guards against silent drift in the
meantime.
- **`nextStory` throw**: left as a fail-loud, build-time invariant. The
slug always comes from the same `getStaticPaths` collection, so the
throw is effectively unreachable; it surfaces a future-refactor bug
loudly instead of linking to the wrong story.
2026-07-01 12:45:24 +00:00
70 changed files with 3576 additions and 318 deletions

View File

@@ -15,7 +15,7 @@ const { categories } = defineProps<{
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET = -144
const HEADER_OFFSET_PX = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
offset: HEADER_OFFSET_PX,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock

View File

@@ -1,5 +1,5 @@
<li
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
>
<slot />
</li>

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

@@ -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

@@ -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
}

View File

@@ -0,0 +1,164 @@
import { createApp, h, nextTick, reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { components } from '@/types/comfyRegistryTypes'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
type NodePack = components['schemas']['Node']
const {
managerStore,
nodePacksState,
nodePacksError,
nodePacksLoading,
nodePacksReady,
startFetch,
cleanup,
useNodePacks
} = vi.hoisted(() => ({
managerStore: {
installedPacksIds: new Set<string>(),
installedPacks: {},
refreshInstalledList: vi.fn(),
isPackInstalled: vi.fn()
},
nodePacksState: { value: [] as NodePack[] },
nodePacksError: { value: undefined as unknown },
nodePacksLoading: { value: false },
nodePacksReady: { value: false },
startFetch: vi.fn(),
cleanup: vi.fn(),
useNodePacks: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => managerStore
}))
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useNodePacks',
() => ({
useNodePacks
})
)
function mountInstalledPacks() {
let result: ReturnType<typeof useInstalledPacks> | undefined
const app = createApp({
setup() {
result = useInstalledPacks()
return () => h('div')
}
})
app.mount(document.createElement('div'))
if (!result) throw new Error('useInstalledPacks did not initialize')
return {
result,
unmount: () => app.unmount()
}
}
function pack(overrides: Partial<NodePack> = {}): NodePack {
return { id: 'pack-a', ...overrides } as NodePack
}
beforeEach(() => {
managerStore.installedPacksIds = reactive(new Set<string>())
managerStore.installedPacks = reactive({})
managerStore.refreshInstalledList.mockReset().mockResolvedValue(undefined)
managerStore.isPackInstalled.mockReset().mockReturnValue(false)
startFetch.mockReset().mockResolvedValue([])
cleanup.mockReset()
useNodePacks.mockReset().mockReturnValue({
error: nodePacksError,
isLoading: nodePacksLoading,
isReady: nodePacksReady,
nodePacks: nodePacksState,
startFetch,
cleanup
})
})
describe('useInstalledPacks', () => {
it('refreshes an empty installed list before fetching packs', async () => {
const { result, unmount } = mountInstalledPacks()
await result.startFetchInstalled()
expect(managerStore.refreshInstalledList).toHaveBeenCalledTimes(1)
expect(startFetch).toHaveBeenCalledTimes(1)
unmount()
})
it('does not refresh when installed pack ids are already present', async () => {
managerStore.installedPacksIds.add('pack-a')
const { result, unmount } = mountInstalledPacks()
await result.startFetchInstalled()
expect(managerStore.refreshInstalledList).not.toHaveBeenCalled()
expect(startFetch).toHaveBeenCalledTimes(1)
unmount()
})
it('prevents duplicate initialization fetches', async () => {
let releaseRefresh: (() => void) | undefined
managerStore.refreshInstalledList.mockReturnValue(
new Promise<void>((resolve) => {
releaseRefresh = resolve
})
)
const { result, unmount } = mountInstalledPacks()
const firstFetch = result.startFetchInstalled()
await result.startFetchInstalled()
releaseRefresh?.()
await firstFetch
expect(managerStore.refreshInstalledList).toHaveBeenCalledTimes(1)
expect(startFetch).toHaveBeenCalledTimes(1)
unmount()
})
it('fetches again when installed ids change', async () => {
const { unmount } = mountInstalledPacks()
managerStore.installedPacksIds.add('pack-b')
await nextTick()
expect(startFetch).toHaveBeenCalledTimes(1)
unmount()
})
it('filters and exposes installed pack versions', () => {
managerStore.isPackInstalled.mockImplementation((id?: string) => id === 'x')
Object.assign(managerStore.installedPacks, {
a: { cnr_id: 'x', ver: '1.0.0' },
b: { aux_id: 'y' },
c: { ver: 'missing-id' }
})
const { result, unmount } = mountInstalledPacks()
expect(
result.filterInstalledPack([pack({ id: 'x' }), pack({ id: 'z' })])
).toEqual([pack({ id: 'x' })])
expect(result.installedPacksWithVersions.value).toEqual([
{ id: 'x', version: '1.0.0' },
{ id: 'y', version: '' }
])
unmount()
})
it('cleans up node pack fetching on unmount', () => {
const { unmount } = mountInstalledPacks()
unmount()
expect(cleanup).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,269 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { components } from '@/types/comfyRegistryTypes'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
type NodePack = components['schemas']['Node']
type CompatibilityCheck = {
hasConflict: boolean
conflicts: ConflictDetail[]
}
const { managerStore, showDialog, checkNodeCompatibility } = vi.hoisted(() => ({
managerStore: {
installPack: { call: vi.fn(), clear: vi.fn() },
isPackInstalling: vi.fn((_id?: string) => false),
isPackInstalled: vi.fn((_id?: string) => false)
},
showDialog: vi.fn(),
checkNodeCompatibility: vi.fn(
(): CompatibilityCheck => ({ hasConflict: false, conflicts: [] })
)
}))
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => managerStore
}))
vi.mock(
'@/workbench/extensions/manager/composables/useNodeConflictDialog',
() => ({
useNodeConflictDialog: () => ({ show: showDialog })
})
)
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: () => ({ checkNodeCompatibility })
})
)
function pack(over: Partial<NodePack> = {}): NodePack {
return fromPartial<NodePack>({ id: 'pack-a', name: 'Pack A', ...over })
}
function conflict(overrides: Partial<ConflictDetail> = {}): ConflictDetail {
return {
type: 'os',
current_value: 'linux',
required_value: 'darwin',
...overrides
}
}
beforeEach(() => {
managerStore.installPack.call.mockReset().mockResolvedValue(undefined)
managerStore.installPack.clear.mockReset()
managerStore.isPackInstalling.mockReset().mockReturnValue(false)
managerStore.isPackInstalled.mockReset().mockReturnValue(false)
showDialog.mockReset()
checkNodeCompatibility.mockReset().mockReturnValue({
hasConflict: false,
conflicts: []
})
})
describe('usePackInstall', () => {
it('reports isInstalling when any pack is installing', () => {
managerStore.isPackInstalling.mockImplementation(
(id?: string) => id === 'pack-b'
)
const { isInstalling } = usePackInstall(() => [
pack(),
pack({ id: 'pack-b' })
])
expect(isInstalling.value).toBe(true)
})
it('reports not installing for an empty or idle pack list', () => {
expect(usePackInstall(() => []).isInstalling.value).toBe(false)
expect(usePackInstall(() => [pack()]).isInstalling.value).toBe(false)
})
it('installs each pack and clears the command afterward', async () => {
const { performInstallation } = usePackInstall(() => [])
await performInstallation([
pack({
id: 'a',
latest_version: { version: '1.2.0' }
} as Partial<NodePack>),
pack({ id: 'b', publisher: { name: 'Unclaimed' } } as Partial<NodePack>)
])
expect(managerStore.installPack.call).toHaveBeenCalledTimes(2)
expect(managerStore.installPack.call).toHaveBeenCalledWith(
expect.objectContaining({ id: 'a', selected_version: '1.2.0' })
)
expect(managerStore.installPack.call).toHaveBeenCalledWith(
expect.objectContaining({ id: 'b', selected_version: 'nightly' })
)
expect(managerStore.installPack.clear).toHaveBeenCalled()
})
it('installAllPacks installs only the not-yet-installed packs', async () => {
managerStore.isPackInstalled.mockImplementation(
(id?: string) => id === 'installed'
)
const { installAllPacks } = usePackInstall(() => [
pack({ id: 'installed' }),
pack({ id: 'fresh' })
])
await installAllPacks()
expect(managerStore.installPack.call).toHaveBeenCalledTimes(1)
expect(managerStore.installPack.call).toHaveBeenCalledWith(
expect.objectContaining({ id: 'fresh' })
)
})
it('installAllPacks returns early for empty or already installed packs', async () => {
await usePackInstall(() => []).installAllPacks()
managerStore.isPackInstalled.mockReturnValue(true)
await usePackInstall(() => [pack({ id: 'installed' })]).installAllPacks()
expect(managerStore.installPack.call).not.toHaveBeenCalled()
expect(managerStore.installPack.clear).not.toHaveBeenCalled()
})
it('installAllPacks opens the conflict dialog instead of installing when conflicted', async () => {
const osConflict = conflict()
checkNodeCompatibility.mockReturnValue({
hasConflict: true,
conflicts: [osConflict]
})
const { installAllPacks } = usePackInstall(
() => [pack({ id: 'x' })],
() => true,
() => [osConflict]
)
await installAllPacks()
expect(showDialog).toHaveBeenCalledTimes(1)
expect(showDialog).toHaveBeenCalledWith(
expect.objectContaining({
conflictedPackages: [
expect.objectContaining({
package_id: 'x',
package_name: 'Pack A',
has_conflict: true,
conflicts: [osConflict],
is_compatible: false
})
]
})
)
expect(managerStore.installPack.call).not.toHaveBeenCalled()
})
it('installAllPacks stops when conflict details are unavailable', async () => {
const { installAllPacks } = usePackInstall(
() => [pack({ id: 'x' })],
() => true
)
await installAllPacks()
expect(showDialog).not.toHaveBeenCalled()
expect(managerStore.installPack.call).not.toHaveBeenCalled()
})
it('conflict dialog payload falls back for unnamed package data', async () => {
checkNodeCompatibility.mockReturnValue({
hasConflict: true,
conflicts: [conflict()]
})
const { installAllPacks } = usePackInstall(
() => [pack({ id: undefined, name: undefined })],
() => true,
() => [conflict()]
)
await installAllPacks()
expect(showDialog).toHaveBeenCalledWith(
expect.objectContaining({
conflictedPackages: [
expect.objectContaining({
package_id: '',
package_name: ''
})
]
})
)
})
it('conflict dialog action installs only packs still missing', async () => {
checkNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
managerStore.isPackInstalled.mockImplementation(
(id?: string) => id === 'installed'
)
const { installAllPacks } = usePackInstall(
() => [pack({ id: 'installed' }), pack({ id: 'fresh' })],
() => true,
() => [conflict()]
)
await installAllPacks()
const [{ onButtonClick }] = showDialog.mock.calls[0]
await onButtonClick()
expect(managerStore.installPack.call).toHaveBeenCalledTimes(1)
expect(managerStore.installPack.call).toHaveBeenCalledWith(
expect.objectContaining({ id: 'fresh' })
)
expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1)
})
it('conflict dialog action returns when every pack is already installed', async () => {
managerStore.isPackInstalled.mockReturnValue(true)
const { installAllPacks } = usePackInstall(
() => [pack({ id: 'installed' })],
() => true,
() => [conflict()]
)
await installAllPacks()
const [{ onButtonClick }] = showDialog.mock.calls[0]
await onButtonClick()
expect(managerStore.installPack.call).not.toHaveBeenCalled()
expect(managerStore.installPack.clear).not.toHaveBeenCalled()
})
it('clears the command when payload validation rejects', async () => {
const { performInstallation } = usePackInstall(() => [])
await expect(
performInstallation([pack({ id: undefined })])
).rejects.toThrow('Node ID is required for installation')
expect(managerStore.installPack.call).not.toHaveBeenCalled()
expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1)
})
it('leaves command cleanup in finally when one install fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
managerStore.installPack.call
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('failed'))
const { performInstallation } = usePackInstall(() => [])
await performInstallation([pack({ id: 'a' }), pack({ id: 'b' })])
expect(consoleError).toHaveBeenCalledWith(
'[usePackInstall] Some installations failed:',
[expect.any(Error)]
)
expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1)
consoleError.mockRestore()
})
})

View File

@@ -1,6 +1,6 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { t } from '@/i18n'
import type { components } from '@/types/comfyRegistryTypes'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
@@ -18,7 +18,6 @@ export function usePackInstall(
const managerStore = useComfyManagerStore()
const { show: showNodeConflictDialog } = useNodeConflictDialog()
const { checkNodeCompatibility } = useConflictDetection()
const { t } = useI18n()
// Check if any of the packs are currently being installed
const isInstalling = computed(() => {

View File

@@ -0,0 +1,82 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { components } from '@/types/comfyRegistryTypes'
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
type NodePack = components['schemas']['Node']
const { managerStore } = vi.hoisted(() => ({
managerStore: {
isPackInstalled: vi.fn(),
isPackEnabled: vi.fn(),
getInstalledPackVersion: vi.fn()
}
}))
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => managerStore
}))
function pack(overrides: Partial<NodePack> = {}): NodePack {
return {
id: 'pack-a',
latest_version: { version: '1.2.0' },
...overrides
} as NodePack
}
beforeEach(() => {
managerStore.isPackInstalled.mockReset().mockReturnValue(true)
managerStore.isPackEnabled.mockReset().mockReturnValue(true)
managerStore.getInstalledPackVersion.mockReset().mockReturnValue('1.0.0')
})
describe('usePackUpdateStatus', () => {
it('detects semver updates for installed packs', () => {
const status = usePackUpdateStatus(pack())
expect(status.installedVersion.value).toBe('1.0.0')
expect(status.latestVersion.value).toBe('1.2.0')
expect(status.isNightlyPack.value).toBe(false)
expect(status.isUpdateAvailable.value).toBe(true)
expect(status.canTryNightlyUpdate.value).toBe(false)
})
it('blocks update prompts when required version data is absent', () => {
managerStore.isPackInstalled.mockReturnValue(false)
expect(usePackUpdateStatus(pack()).isUpdateAvailable.value).toBe(false)
managerStore.isPackInstalled.mockReturnValue(true)
managerStore.getInstalledPackVersion.mockReturnValue('')
expect(usePackUpdateStatus(pack()).isUpdateAvailable.value).toBe(false)
managerStore.getInstalledPackVersion.mockReturnValue('1.0.0')
expect(
usePackUpdateStatus(pack({ latest_version: undefined })).isUpdateAvailable
.value
).toBe(false)
})
it('allows enabled nightly packs to try update without semver comparison', () => {
managerStore.getInstalledPackVersion.mockReturnValue('nightly')
const status = usePackUpdateStatus(pack())
expect(status.isNightlyPack.value).toBe(true)
expect(status.isUpdateAvailable.value).toBe(false)
expect(status.canTryNightlyUpdate.value).toBe(true)
})
it('tracks reactive pack sources', () => {
const nodePack = ref(pack({ latest_version: { version: '1.0.0' } }))
const status = usePackUpdateStatus(nodePack)
expect(status.isUpdateAvailable.value).toBe(false)
nodePack.value = pack({ latest_version: { version: '2.0.0' } })
expect(status.latestVersion.value).toBe('2.0.0')
expect(status.isUpdateAvailable.value).toBe(true)
})
})

View File

@@ -7,21 +7,13 @@ import type { components } from '@/types/comfyRegistryTypes'
import { usePacksSelection } from '@/workbench/extensions/manager/composables/nodePack/usePacksSelection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual('vue-i18n')
return {
...actual,
useI18n: () => ({
t: vi.fn((key) => key)
})
}
})
type NodePack = components['schemas']['Node']
describe('usePacksSelection', () => {
let managerStore: ReturnType<typeof useComfyManagerStore>
let mockIsPackInstalled: (packName: string | undefined) => boolean
let mockGetInstalledPackVersion: (packName: string) => string | undefined
let mockIsPackEnabled: (packName: string | undefined) => boolean
const createMockPack = (id: string): NodePack => ({
id,
@@ -42,9 +34,12 @@ describe('usePacksSelection', () => {
managerStore = useComfyManagerStore()
// Mock the isPackInstalled method
mockIsPackInstalled = vi.fn()
mockGetInstalledPackVersion = vi.fn()
mockIsPackEnabled = vi.fn()
managerStore.isPackInstalled = mockIsPackInstalled
managerStore.getInstalledPackVersion = mockGetInstalledPackVersion
managerStore.isPackEnabled = mockIsPackEnabled
})
afterEach(() => {
@@ -375,5 +370,35 @@ describe('usePacksSelection', () => {
expect(installedPacks.value).toHaveLength(2)
expect(notInstalledPacks.value).toHaveLength(0)
})
it('should only include enabled installed packs with non-semver versions as nightly', () => {
const nodePacks = ref<NodePack[]>([
{ ...createMockPack('missing-id'), id: undefined },
createMockPack('no-version'),
createMockPack('stable'),
createMockPack('disabled-nightly'),
createMockPack('enabled-nightly')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
vi.mocked(mockGetInstalledPackVersion).mockImplementation((id) => {
const versions: Record<string, string | undefined> = {
stable: '1.2.3',
'disabled-nightly': 'abc123',
'enabled-nightly': 'def456'
}
return versions[id]
})
vi.mocked(mockIsPackEnabled).mockImplementation(
(id) => id === 'enabled-nightly'
)
const { nightlyPacks, hasNightlyPacks } = usePacksSelection(nodePacks)
expect(nightlyPacks.value.map((pack) => pack.id)).toEqual([
'enabled-nightly'
])
expect(hasNightlyPacks.value).toBe(true)
})
})
})

View File

@@ -0,0 +1,234 @@
import { createApp, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { components } from '@/types/comfyRegistryTypes'
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
import type * as VueUse from '@vueuse/core'
type GraphNode = {
type?: string
properties?: {
cnr_id?: unknown
aux_id?: unknown
ver?: unknown
}
}
type NodePack = components['schemas']['Node']
const {
appState,
nodeDefStore,
registryStore,
systemStatsStore,
nodePacksState,
nodePacksError,
nodePacksLoading,
nodePacksReady,
startFetch,
cleanup,
useNodePacks
} = vi.hoisted(() => ({
appState: {
rootGraph: undefined as undefined | { nodes: GraphNode[] }
},
nodeDefStore: {
nodeDefsByName: {} as Record<string, { isCoreNode?: boolean }>
},
registryStore: {
inferPackFromNodeName: { call: vi.fn() }
},
systemStatsStore: {
systemStats: undefined as
| undefined
| { system?: { comfyui_version?: string } },
refetchSystemStats: vi.fn()
},
nodePacksState: { value: [] as NodePack[] },
nodePacksError: { value: undefined as unknown },
nodePacksLoading: { value: false },
nodePacksReady: { value: false },
startFetch: vi.fn(),
cleanup: vi.fn(),
useNodePacks: vi.fn()
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<typeof VueUse>()
return {
...actual,
createSharedComposable: <T extends (...args: never[]) => unknown>(fn: T) =>
fn
}
})
vi.mock('@/scripts/app', () => ({
app: appState
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => registryStore
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => nodeDefStore
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: () => systemStatsStore
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
mapAllNodes: (
graph: { nodes: GraphNode[] },
mapper: (node: GraphNode) => unknown
) => graph.nodes.map(mapper)
}))
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useNodePacks',
() => ({
useNodePacks
})
)
function mountWorkflowPacks() {
let result: ReturnType<typeof useWorkflowPacks> | undefined
const app = createApp({
setup() {
result = useWorkflowPacks()
return () => h('div')
}
})
app.mount(document.createElement('div'))
if (!result) throw new Error('useWorkflowPacks did not initialize')
return {
result,
unmount: () => app.unmount()
}
}
function node(overrides: GraphNode = {}): GraphNode {
return {
type: 'CustomNode',
properties: {},
...overrides
}
}
function pack(overrides: Partial<NodePack> = {}): NodePack {
return { id: 'pack-a', ...overrides } as NodePack
}
beforeEach(() => {
appState.rootGraph = { nodes: [] }
nodeDefStore.nodeDefsByName = {}
registryStore.inferPackFromNodeName.call
.mockReset()
.mockResolvedValue(undefined)
systemStatsStore.systemStats = undefined
systemStatsStore.refetchSystemStats.mockReset().mockResolvedValue(undefined)
startFetch.mockReset().mockResolvedValue([])
cleanup.mockReset()
useNodePacks.mockReset().mockReturnValue({
error: nodePacksError,
isLoading: nodePacksLoading,
isReady: nodePacksReady,
nodePacks: nodePacksState,
startFetch,
cleanup
})
})
describe('useWorkflowPacks', () => {
it('fetches explicit workflow packs and trims versions', async () => {
appState.rootGraph = {
nodes: [
node({ properties: { cnr_id: 'pack-a', ver: ' v1.2.3\n' } }),
node({ properties: { aux_id: 'pack-b' } }),
node({ properties: { cnr_id: 'comfy-core' } })
]
}
const { result, unmount } = mountWorkflowPacks()
await result.startFetchWorkflowPacks()
expect(useNodePacks).toHaveBeenCalled()
const idsSource = useNodePacks.mock.calls[0][0]
expect(idsSource.value).toEqual(['pack-a', 'pack-b'])
expect(startFetch).toHaveBeenCalledTimes(1)
expect(
result.filterWorkflowPack([
pack({ id: 'pack-a' }),
pack({ id: 'comfy-core' })
])
).toEqual([pack({ id: 'pack-a' })])
unmount()
})
it('infers core node packs from system stats', async () => {
nodeDefStore.nodeDefsByName = { KSampler: { isCoreNode: true } }
systemStatsStore.systemStats = { system: { comfyui_version: '0.4.0' } }
appState.rootGraph = { nodes: [node({ type: 'KSampler' })] }
const { result, unmount } = mountWorkflowPacks()
await result.startFetchWorkflowPacks()
const idsSource = useNodePacks.mock.calls[0][0]
expect(idsSource.value).toEqual(['comfy-core'])
expect(systemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
unmount()
})
it('refetches system stats and falls back to nightly for core nodes', async () => {
nodeDefStore.nodeDefsByName = { KSampler: { isCoreNode: true } }
appState.rootGraph = { nodes: [node({ type: 'KSampler' })] }
const { result, unmount } = mountWorkflowPacks()
await result.startFetchWorkflowPacks()
const idsSource = useNodePacks.mock.calls[0][0]
expect(idsSource.value).toEqual(['comfy-core'])
expect(systemStatsStore.refetchSystemStats).toHaveBeenCalled()
unmount()
})
it('infers registry packs and tracks unresolved nodes', async () => {
registryStore.inferPackFromNodeName.call.mockImplementation(
(name: string) =>
name === 'KnownNode'
? Promise.resolve({
id: 'registry-pack',
latest_version: { version: '3.0.0' }
})
: Promise.resolve(undefined)
)
appState.rootGraph = {
nodes: [node({ type: 'KnownNode' }), node({ type: 'MissingNode' })]
}
const { result, unmount } = mountWorkflowPacks()
await result.startFetchWorkflowPacks()
const idsSource = useNodePacks.mock.calls[0][0]
expect(idsSource.value).toEqual(['registry-pack'])
expect(result.unresolvedNodeNames.value).toEqual(['MissingNode'])
unmount()
})
it('resets workflow packs when no graph is ready and cleans up on unmount', async () => {
appState.rootGraph = undefined
const { result, unmount } = mountWorkflowPacks()
await result.startFetchWorkflowPacks()
unmount()
const idsSource = useNodePacks.mock.calls[0][0]
expect(idsSource.value).toEqual([])
expect(cleanup).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
import type * as VueUse from '@vueuse/core'
type Listener = () => void
const {
listeners,
managerStore,
settingStore,
commandStore,
workflowService,
managerService,
runFullConflictAnalysis,
useEventListener
} = vi.hoisted(() => ({
listeners: new Map<string, Listener>(),
managerStore: {
setStale: vi.fn()
},
settingStore: {
get: vi.fn(),
set: vi.fn()
},
commandStore: {
execute: vi.fn()
},
workflowService: {
reloadCurrentWorkflow: vi.fn()
},
managerService: {
rebootComfyUI: vi.fn()
},
runFullConflictAnalysis: vi.fn(),
useEventListener: vi.fn(
(_target: unknown, event: string, listener: Listener) => {
listeners.set(event, listener)
return vi.fn(() => listeners.delete(event))
}
)
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<typeof VueUse>()
return {
...actual,
createSharedComposable: <T extends (...args: never[]) => unknown>(fn: T) =>
fn,
useEventListener
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingStore
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => workflowService
}))
vi.mock('@/scripts/api', () => ({
api: {}
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => commandStore
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: () => ({ runFullConflictAnalysis })
})
)
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: () => managerService
}))
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => managerStore
}))
beforeEach(() => {
vi.useFakeTimers()
listeners.clear()
managerStore.setStale.mockReset()
settingStore.get.mockReset().mockReturnValue(false)
settingStore.set.mockReset().mockResolvedValue(undefined)
commandStore.execute.mockReset().mockResolvedValue(undefined)
workflowService.reloadCurrentWorkflow.mockReset().mockResolvedValue(undefined)
managerService.rebootComfyUI.mockReset().mockResolvedValue(undefined)
runFullConflictAnalysis.mockReset().mockResolvedValue(undefined)
useEventListener.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useApplyChanges', () => {
it('reboots, handles reconnect, refreshes state, and closes after completion', async () => {
const onClose = vi.fn()
const applyChanges = useApplyChanges()
const applying = applyChanges.applyChanges(onClose)
listeners.get('reconnected')?.()
await applying
await vi.runAllTimersAsync()
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
true
)
expect(managerService.rebootComfyUI).toHaveBeenCalled()
expect(managerStore.setStale).toHaveBeenCalled()
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.RefreshNodeDefinitions'
)
expect(workflowService.reloadCurrentWorkflow).toHaveBeenCalled()
expect(runFullConflictAnalysis).toHaveBeenCalled()
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false
)
expect(applyChanges.isRestarting.value).toBe(false)
expect(applyChanges.isRestartCompleted.value).toBe(true)
expect(onClose).toHaveBeenCalled()
})
it('ignores duplicate apply requests while restarting', async () => {
managerService.rebootComfyUI.mockReturnValue(new Promise(() => {}))
const applyChanges = useApplyChanges()
void applyChanges.applyChanges()
await applyChanges.applyChanges()
expect(managerService.rebootComfyUI).toHaveBeenCalledTimes(1)
})
it('restores the toast setting and reports timeout when reconnect never arrives', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const applyChanges = useApplyChanges()
await applyChanges.applyChanges()
await vi.advanceTimersByTimeAsync(120_000)
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false
)
expect(applyChanges.isRestarting.value).toBe(false)
expect(applyChanges.isRestartCompleted.value).toBe(false)
expect(errorSpy).toHaveBeenCalledWith(
'[useApplyChanges] Reconnect timed out'
)
errorSpy.mockRestore()
})
it('restores state and rethrows when reboot fails', async () => {
const onClose = vi.fn()
const error = new Error('reboot failed')
managerService.rebootComfyUI.mockRejectedValue(error)
const applyChanges = useApplyChanges()
await expect(applyChanges.applyChanges(onClose)).rejects.toThrow(error)
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false
)
expect(applyChanges.isRestarting.value).toBe(false)
expect(applyChanges.isRestartCompleted.value).toBe(false)
expect(onClose).toHaveBeenCalled()
})
})

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
@@ -10,10 +11,15 @@ import { useInstalledPacks } from '@/workbench/extensions/manager/composables/no
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import type { ConflictAcknowledgmentState } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import {
checkAcceleratorCompatibility,
checkOSCompatibility
} from '@/workbench/extensions/manager/utils/systemCompatibility'
import { checkVersionCompatibility } from '@/workbench/extensions/manager/utils/versionUtil'
// Mock @vueuse/core until function
@@ -118,28 +124,28 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
describe('useConflictDetection', () => {
let pinia: ReturnType<typeof createTestingPinia>
const mockComfyManagerService = {
const mockComfyManagerService = fromPartial<
ReturnType<typeof useComfyManagerService>
>({
getImportFailInfoBulk: vi.fn(),
isLoading: ref(false),
error: ref<string | null>(null)
} as Partial<ReturnType<typeof useComfyManagerService>> as ReturnType<
typeof useComfyManagerService
>
})
const mockRegistryService = {
const mockRegistryService = fromPartial<
ReturnType<typeof useComfyRegistryService>
>({
getBulkNodeVersions: vi.fn(),
isLoading: ref(false),
error: ref<string | null>(null)
} as Partial<ReturnType<typeof useComfyRegistryService>> as ReturnType<
typeof useComfyRegistryService
>
})
// Create a ref that can be modified in tests
const mockInstalledPacksWithVersions = ref<{ id: string; version: string }[]>(
[]
)
const mockInstalledPacks = {
const mockInstalledPacks = fromPartial<ReturnType<typeof useInstalledPacks>>({
startFetchInstalled: vi.fn(),
installedPacks: ref<components['schemas']['Node'][]>([]),
installedPacksWithVersions: computed(
@@ -148,20 +154,20 @@ describe('useConflictDetection', () => {
isReady: ref(false),
isLoading: ref(false),
error: ref<unknown>(null)
} as Partial<ReturnType<typeof useInstalledPacks>> as ReturnType<
typeof useInstalledPacks
>
})
const mockManagerStore = {
isPackEnabled: vi.fn()
} as Partial<ReturnType<typeof useComfyManagerStore>> as ReturnType<
typeof useComfyManagerStore
>
const mockManagerStore = fromPartial<ReturnType<typeof useComfyManagerStore>>(
{
isPackEnabled: vi.fn()
}
)
// Create refs that can be used to control computed properties
let mockConflictedPackages: ConflictDetectionResult[] = []
const mockConflictStore = {
const mockConflictStore = fromPartial<
ReturnType<typeof useConflictDetectionStore>
>({
get hasConflicts() {
return mockConflictedPackages.some((p) => p.has_conflict)
},
@@ -180,15 +186,15 @@ describe('useConflictDetection', () => {
},
setConflictedPackages: vi.fn(),
clearConflicts: vi.fn()
} as Partial<ReturnType<typeof useConflictDetectionStore>> as ReturnType<
typeof useConflictDetectionStore
>
})
const mockIsInitialized = true
const mockSystemStatsStore = {
const mockSystemStatsStore = fromPartial<
ReturnType<typeof useSystemStatsStore>
>({
systemStats: {
system: {
os: 'darwin', // sys.platform returns 'darwin' for macOS
os: 'darwin',
ram_total: 17179869184,
ram_free: 8589934592,
comfyui_version: '0.3.41',
@@ -212,26 +218,24 @@ describe('useConflictDetection', () => {
]
},
isInitialized: mockIsInitialized,
_customProperties: new Set<string>()
} as Partial<ReturnType<typeof useSystemStatsStore>> as ReturnType<
typeof useSystemStatsStore
>
})
const mockAcknowledgment = {
checkComfyUIVersionChange: vi.fn(),
const mockShouldShowConflictModal = ref(false)
const mockAcknowledgment = fromPartial<
ReturnType<typeof useConflictAcknowledgment>
>({
acknowledgmentState: computed(
() => ({}) as Partial<ConflictAcknowledgmentState>
),
shouldShowConflictModal: computed(() => false),
shouldShowConflictModal: computed(() => mockShouldShowConflictModal.value),
shouldShowRedDot: computed(() => false),
shouldShowManagerBanner: computed(() => false),
dismissRedDotNotification: vi.fn(),
dismissWarningBanner: vi.fn(),
markConflictsAsSeen: vi.fn()
} as Partial<ReturnType<typeof useConflictAcknowledgment>> as ReturnType<
typeof useConflictAcknowledgment
>
})
beforeEach(() => {
vi.clearAllMocks()
@@ -246,6 +250,12 @@ describe('useConflictDetection', () => {
vi.mocked(useInstalledPacks).mockReturnValue(mockInstalledPacks)
vi.mocked(useComfyManagerStore).mockReturnValue(mockManagerStore)
vi.mocked(useConflictDetectionStore).mockReturnValue(mockConflictStore)
vi.mocked(useManagerState).mockReturnValue({
isNewManagerUI: ref(true)
} as ReturnType<typeof useManagerState>)
vi.mocked(checkVersionCompatibility).mockReturnValue(null)
vi.mocked(checkOSCompatibility).mockReturnValue(null)
vi.mocked(checkAcceleratorCompatibility).mockReturnValue(null)
// Reset mock implementations
vi.mocked(mockInstalledPacks.startFetchInstalled).mockResolvedValue(
@@ -263,6 +273,7 @@ describe('useConflictDetection', () => {
mockInstalledPacksWithVersions.value = []
// Reset conflicted packages
mockConflictedPackages = []
mockShouldShowConflictModal.value = false
})
afterEach(() => {
@@ -296,6 +307,21 @@ describe('useConflictDetection', () => {
accelerator: ''
})
})
it('falls back when collecting system environment throws', async () => {
vi.mocked(useSystemStatsStore).mockImplementation(() => {
throw new Error('stats unavailable')
})
const { collectSystemEnvironment } = useConflictDetection()
await expect(collectSystemEnvironment()).resolves.toEqual({
comfyui_version: undefined,
frontend_version: undefined,
os: undefined,
accelerator: undefined
})
})
})
describe('conflict detection', () => {
@@ -446,6 +472,240 @@ describe('useConflictDetection', () => {
required_value: 'Import error'
})
})
it('uses fallbacks for pending packages and missing registry metadata', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{ id: 'pending-pack' } as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'missing-pack', version: '1.0.0' },
{ id: 'pending-pack', version: '2.0.0' }
]
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: [
{
status: 'success' as const,
identifier: { node_id: 'pending-pack', version: '2.0.0' },
node_version: {
status: 'NodeVersionStatusPending' as const,
version: '2.0.0',
publisher_id: 'publisher',
node_id: 'pending-pack',
created_at: '2024-01-01T00:00:00Z',
supported_comfyui_version: undefined,
supported_comfyui_frontend_version: undefined,
supported_os: undefined,
supported_accelerators: undefined
} as components['schemas']['NodeVersion']
}
]
})
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results).toHaveLength(1)
expect(result.results[0]).toMatchObject({
package_id: 'pending-pack',
has_conflict: true
})
expect(result.results[0].conflicts).toContainEqual({
type: 'pending',
current_value: 'installed',
required_value: 'not_pending'
})
})
it('records compatibility conflicts from version, OS, and accelerator checks', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{
id: 'compat-pack',
name: 'Compatibility Pack'
} as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'compat-pack', version: '1.0.0' }
]
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: [
{
status: 'success' as const,
identifier: { node_id: 'compat-pack', version: '1.0.0' },
node_version: {
status: 'NodeVersionStatusActive' as const,
version: '1.0.0',
publisher_id: 'publisher',
node_id: 'compat-pack',
created_at: '2024-01-01T00:00:00Z',
supported_comfyui_version: '>=9.0.0',
supported_comfyui_frontend_version: '>=9.0.0',
supported_os: ['Windows'],
supported_accelerators: ['CUDA']
} as components['schemas']['NodeVersion']
}
]
})
vi.mocked(checkVersionCompatibility).mockImplementation((type) => ({
type,
current_value: type,
required_value: '>=9.0.0'
}))
vi.mocked(checkOSCompatibility).mockReturnValue({
type: 'os',
current_value: 'macOS',
required_value: 'Windows'
})
vi.mocked(checkAcceleratorCompatibility).mockReturnValue({
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
})
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results[0].conflicts).toEqual(
expect.arrayContaining([
{
type: 'comfyui_version',
current_value: 'comfyui_version',
required_value: '>=9.0.0'
},
{
type: 'frontend_version',
current_value: 'frontend_version',
required_value: '>=9.0.0'
},
{ type: 'os', current_value: 'macOS', required_value: 'Windows' },
{
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
}
])
)
})
it('returns no results when installed packs are not ready', async () => {
mockInstalledPacks.isReady.value = false
mockInstalledPacks.installedPacks.value = [
{ id: 'not-ready' } as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'not-ready', version: '1.0.0' }
]
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results).toEqual([])
expect(mockConflictStore.clearConflicts).toHaveBeenCalled()
})
it('continues when registry bulk lookup fails', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{ id: 'fallback-pack' } as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'fallback-pack', version: '1.0.0' }
]
vi.mocked(mockRegistryService.getBulkNodeVersions).mockRejectedValue(
new Error('registry down')
)
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.success).toBe(true)
expect(result.results).toEqual([
{
package_id: 'fallback-pack',
package_name: 'fallback-pack',
has_conflict: false,
conflicts: [],
is_compatible: true
}
])
})
it('continues when import failure lookup throws', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{ id: 'clean-pack' } as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'clean-pack', version: '1.0.0' }
]
vi.mocked(
mockComfyManagerService.getImportFailInfoBulk
).mockRejectedValue(new Error('manager down'))
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.success).toBe(true)
expect(result.results).toEqual([
{
package_id: 'clean-pack',
package_name: 'clean-pack',
has_conflict: false,
conflicts: [],
is_compatible: true
}
])
})
it('uses unknown import error text when failure details omit messages', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacksWithVersions.value = [
{ id: 'unknown-fail-pack', version: '1.0.0' }
]
vi.mocked(
mockComfyManagerService.getImportFailInfoBulk
).mockResolvedValue({
'unknown-fail-pack': {},
'clean-pack': null
})
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results[0].conflicts).toContainEqual({
type: 'import_failed',
current_value: 'Unknown import error',
required_value: 'Unknown import error'
})
})
it('returns an in-progress response when analysis is already running', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{ id: 'slow-pack' } as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'slow-pack', version: '1.0.0' }
]
let resolveFetch: () => void = () => {}
vi.mocked(mockInstalledPacks.startFetchInstalled).mockReturnValue(
new Promise<void>((resolve) => {
resolveFetch = resolve
})
)
const { runFullConflictAnalysis } = useConflictDetection()
const firstRun = runFullConflictAnalysis()
const secondRun = await runFullConflictAnalysis()
resolveFetch()
await firstRun
expect(secondRun).toMatchObject({
success: false,
error_message: 'Already detecting conflicts'
})
})
})
describe('computed properties', () => {
@@ -490,6 +750,153 @@ describe('useConflictDetection', () => {
)
])
).resolves.not.toThrow()
expect(mockConflictStore.clearConflicts).toHaveBeenCalled()
})
it('skips initialization when the new manager UI is disabled', async () => {
vi.mocked(useManagerState).mockReturnValue({
isNewManagerUI: ref(false)
} as ReturnType<typeof useManagerState>)
const { initializeConflictDetection } = useConflictDetection()
await initializeConflictDetection()
expect(mockInstalledPacks.startFetchInstalled).not.toHaveBeenCalled()
})
it('ignores initialization errors', async () => {
vi.mocked(useSystemStatsStore).mockImplementation(() => {
throw new Error('stats unavailable')
})
const { initializeConflictDetection } = useConflictDetection()
await expect(initializeConflictDetection()).resolves.toBeUndefined()
})
})
describe('modal gating', () => {
it('shows the modal after update when conflicts exist and acknowledgment allows it', async () => {
mockConflictedPackages = [
{
package_id: 'conflict-pack',
package_name: 'Conflict Pack',
has_conflict: true,
is_compatible: false,
conflicts: []
}
]
mockShouldShowConflictModal.value = true
mockInstalledPacks.isReady.value = true
mockInstalledPacksWithVersions.value = []
const { shouldShowConflictModalAfterUpdate } = useConflictDetection()
await expect(shouldShowConflictModalAfterUpdate()).resolves.toBe(true)
})
it('keeps the modal hidden when acknowledgment blocks it', async () => {
mockConflictedPackages = [
{
package_id: 'conflict-pack',
package_name: 'Conflict Pack',
has_conflict: true,
is_compatible: false,
conflicts: []
}
]
mockShouldShowConflictModal.value = false
mockInstalledPacks.isReady.value = true
mockInstalledPacksWithVersions.value = []
const { shouldShowConflictModalAfterUpdate } = useConflictDetection()
await expect(shouldShowConflictModalAfterUpdate()).resolves.toBe(false)
})
})
describe('node compatibility', () => {
it('reports compatibility conflicts for registry nodes', async () => {
const { collectSystemEnvironment, checkNodeCompatibility } =
useConflictDetection()
await collectSystemEnvironment()
vi.mocked(checkOSCompatibility).mockReturnValue({
type: 'os',
current_value: 'macOS',
required_value: 'Windows'
})
vi.mocked(checkAcceleratorCompatibility).mockReturnValue({
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
})
vi.mocked(checkVersionCompatibility).mockImplementation((type) => ({
type,
current_value: type,
required_value: '>=9.0.0'
}))
const result = checkNodeCompatibility({
id: 'node-pack',
status: 'NodeStatusBanned',
supported_os: ['Windows'],
supported_accelerators: ['CUDA'],
supported_comfyui_version: '>=9.0.0',
supported_comfyui_frontend_version: '>=9.0.0'
} as components['schemas']['Node'])
expect(result).toEqual({
hasConflict: true,
conflicts: expect.arrayContaining([
{ type: 'os', current_value: 'macOS', required_value: 'Windows' },
{
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
},
{
type: 'comfyui_version',
current_value: 'comfyui_version',
required_value: '>=9.0.0'
},
{
type: 'frontend_version',
current_value: 'frontend_version',
required_value: '>=9.0.0'
},
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
])
})
})
it('reports pending version nodes as incompatible', () => {
const { checkNodeCompatibility } = useConflictDetection()
const result = checkNodeCompatibility({
node_id: 'node-pack',
version: '1.0.0',
publisher_id: 'publisher',
created_at: '2024-01-01T00:00:00Z',
status: 'NodeVersionStatusPending'
} as components['schemas']['NodeVersion'])
expect(result.conflicts).toContainEqual({
type: 'pending',
current_value: 'installed',
required_value: 'not_pending'
})
})
it('reports compatible registry nodes without conflicts', () => {
const { checkNodeCompatibility } = useConflictDetection()
expect(
checkNodeCompatibility({
id: 'node-pack',
status: 'NodeStatusActive'
} as components['schemas']['Node'])
).toEqual({ hasConflict: false, conflicts: [] })
})
})
})

View File

@@ -0,0 +1,280 @@
import type * as VueUse from '@vueuse/core'
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { useManagerDisplayPacks } from '@/workbench/extensions/manager/composables/useManagerDisplayPacks'
type NodePack = components['schemas']['Node']
const { state } = vi.hoisted(() => ({
state: {
installed: [] as NodePack[],
workflow: [] as NodePack[],
installedLoading: false,
workflowLoading: false,
installedReady: true,
workflowReady: true,
startFetchInstalled: vi.fn(),
startFetchWorkflowPacks: vi.fn(),
installedIds: new Set<string>(),
installedVersions: {} as Record<string, string>,
conflicts: [] as { package_id: string }[]
}
}))
vi.mock('@vueuse/core', async (orig) => ({
...(await orig<typeof VueUse>()),
whenever: (source: unknown, callback?: () => void) => {
if (typeof source === 'function' && source() && callback) {
callback()
}
}
}))
vi.mock('@/services/gateway/registrySearchGateway', () => ({
useRegistrySearchGateway: () => ({
getSortValue: (pack: NodePack, field: string) =>
(pack as Record<string, unknown>)[field],
getSortableFields: () => [{ id: 'name', direction: 'asc' }]
})
}))
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useInstalledPacks',
() => ({
useInstalledPacks: () => ({
startFetchInstalled: state.startFetchInstalled,
filterInstalledPack: (packs: NodePack[]) =>
packs.filter((p) => state.installedIds.has(p.id ?? '')),
installedPacks: ref(state.installed),
isLoading: ref(state.installedLoading),
isReady: ref(state.installedReady)
})
})
)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks',
() => ({
useWorkflowPacks: () => ({
startFetchWorkflowPacks: state.startFetchWorkflowPacks,
filterWorkflowPack: (packs: NodePack[]) => packs,
workflowPacks: ref(state.workflow),
isLoading: ref(state.workflowLoading),
isReady: ref(state.workflowReady)
})
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: (id: string | undefined) =>
state.installedIds.has(id ?? ''),
getInstalledPackVersion: (id: string) => state.installedVersions[id]
})
}))
vi.mock('@/workbench/extensions/manager/stores/conflictDetectionStore', () => ({
useConflictDetectionStore: () => ({
get conflictedPackages() {
return state.conflicts
}
})
}))
function pack(id: string, latestVersion?: string): NodePack {
return fromPartial<NodePack>({
id,
name: id,
latest_version: latestVersion ? { version: latestVersion } : undefined
})
}
function display(
tab: ManagerTab,
searchResults: NodePack[] = [],
query = '',
sortField = ''
) {
return useManagerDisplayPacks(
ref(tab),
ref(searchResults),
ref(query),
ref(sortField)
)
}
beforeEach(() => {
state.installed = []
state.workflow = []
state.installedLoading = false
state.workflowLoading = false
state.installedReady = true
state.workflowReady = true
state.startFetchInstalled.mockReset()
state.startFetchWorkflowPacks.mockReset()
state.installedIds = new Set()
state.installedVersions = {}
state.conflicts = []
})
describe('useManagerDisplayPacks', () => {
it('All tab returns the raw search results', () => {
const results = [pack('a'), pack('b')]
expect(display(ManagerTab.All, results).displayPacks.value).toEqual(results)
})
it('NotInstalled tab excludes installed packs', () => {
state.installedIds = new Set(['a'])
const { displayPacks } = display(ManagerTab.NotInstalled, [
pack('a'),
pack('b')
])
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
})
it('AllInstalled tab shows installed packs when not searching', () => {
state.installed = [pack('x'), pack('y')]
const { displayPacks } = display(ManagerTab.AllInstalled)
expect(displayPacks.value.map((p) => p.id)).toEqual(['x', 'y'])
})
it('UpdateAvailable tab keeps only installed packs with a newer latest version', () => {
state.installedIds = new Set(['old', 'current', 'nightly'])
state.installedVersions = {
old: '1.0.0',
current: '2.0.0',
nightly: 'not-semver'
}
state.installed = [
pack('old', '1.2.0'),
pack('current', '2.0.0'),
pack('nightly', '9.9.9'),
pack('uninstalled', '5.0.0')
]
const { displayPacks } = display(ManagerTab.UpdateAvailable)
expect(displayPacks.value.map((p) => p.id)).toEqual(['old'])
})
it('Conflicting tab keeps packs flagged by the conflict store', () => {
state.installed = [pack('a'), pack('b')]
state.conflicts = [{ package_id: 'b' }]
const { displayPacks } = display(ManagerTab.Conflicting)
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
})
it('Missing tab returns workflow packs that are not installed', () => {
state.workflow = [pack('a'), pack('b')]
state.installedIds = new Set(['a'])
const { displayPacks, missingNodePacks } = display(ManagerTab.Missing)
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
expect(missingNodePacks.value.map((p) => p.id)).toEqual(['b'])
})
it('Unresolved tab is always empty', () => {
expect(
display(ManagerTab.Unresolved, [pack('a')]).displayPacks.value
).toEqual([])
})
it('reports loading state scoped to the active tab group', () => {
state.installedLoading = true
state.workflowLoading = false
expect(display(ManagerTab.AllInstalled).isLoading.value).toBe(true)
expect(display(ManagerTab.All).isLoading.value).toBe(false)
state.installedLoading = false
state.workflowLoading = true
expect(display(ManagerTab.Workflow).isLoading.value).toBe(true)
expect(display(ManagerTab.Missing).isLoading.value).toBe(true)
})
it('fetches installed packs when an installed tab is selected and not ready', () => {
state.installedReady = false
display(ManagerTab.AllInstalled)
expect(state.startFetchInstalled).toHaveBeenCalledTimes(1)
expect(state.startFetchWorkflowPacks).not.toHaveBeenCalled()
})
it('fetches workflow and installed packs for missing workflow dependencies', () => {
state.installedReady = false
state.workflowReady = false
display(ManagerTab.Missing)
expect(state.startFetchInstalled).toHaveBeenCalledTimes(1)
expect(state.startFetchWorkflowPacks).toHaveBeenCalledTimes(1)
})
it('filters search results to installed packs on the AllInstalled tab while searching', () => {
state.installedIds = new Set(['a'])
const { displayPacks } = display(
ManagerTab.AllInstalled,
[pack('a'), pack('b')],
'query'
)
expect(displayPacks.value.map((p) => p.id)).toEqual(['a'])
})
it('filters searched update and conflict tabs before applying tab rules', () => {
state.installedIds = new Set(['old', 'conflict'])
state.installedVersions = {
old: '1.0.0',
conflict: '1.0.0'
}
state.conflicts = [{ package_id: 'conflict' }]
const results = [
pack('old', '2.0.0'),
pack('current', '1.0.0'),
pack('conflict', '1.0.0')
]
expect(
display(
ManagerTab.UpdateAvailable,
results,
'query'
).displayPacks.value.map((p) => p.id)
).toEqual(['old'])
expect(
display(ManagerTab.Conflicting, results, 'query').displayPacks.value.map(
(p) => p.id
)
).toEqual(['conflict'])
})
it('filters workflow search results on the Workflow tab while searching', () => {
const { displayPacks } = display(
ManagerTab.Workflow,
[pack('a'), pack('b')],
'query'
)
expect(displayPacks.value.map((p) => p.id)).toEqual(['a', 'b'])
})
it('filters searched missing workflow packs to not-installed packs', () => {
state.installedIds = new Set(['a'])
const { displayPacks } = display(
ManagerTab.Missing,
[pack('a'), pack('b')],
'query'
)
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
})
it('falls back to search results for unknown tabs', () => {
const results = [pack('a')]
expect(
display('unknown' as ManagerTab, results).displayPacks.value
).toEqual(results)
})
it('sorts installed packs by the configured field', () => {
state.installed = [pack('b'), pack('a'), pack('c')]
const { displayPacks } = display(ManagerTab.AllInstalled, [], '', 'name')
expect(displayPacks.value.map((p) => p.id)).toEqual(['a', 'b', 'c'])
})
})

View File

@@ -5,12 +5,23 @@ import { ref } from 'vue'
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
// Mock the app API
const mockAppApi = vi.hoisted(() => ({
addEventListener: vi.fn((type: string, listener: EventListener) => {
mockAppApi.listeners.set(type, listener)
}),
listeners: new Map<string, EventListener>(),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
if (mockAppApi.listeners.get(type) === listener) {
mockAppApi.listeners.delete(type)
}
})
}))
vi.mock('@/scripts/app', () => ({
app: {
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addEventListener: mockAppApi.addEventListener,
removeEventListener: mockAppApi.removeEventListener,
clientId: 'test-client-id'
}
}
@@ -21,6 +32,43 @@ type ManagerTaskHistory = Record<
components['schemas']['TaskHistoryItem']
>
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
type QueueTaskItem = components['schemas']['QueueTaskItem']
function createQueueTask(
uiId: string,
clientId = 'test-client-id'
): QueueTaskItem {
return {
ui_id: uiId,
client_id: clientId,
kind: 'install',
params: {
id: uiId,
version: '1.0.0',
selected_version: '1.0.0',
mode: 'remote',
channel: 'default'
}
}
}
function createTaskState(
overrides: Partial<ManagerTaskQueue> = {}
): ManagerTaskQueue {
return {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {},
...overrides
}
}
function dispatchManagerEvent(type: string, detail: unknown) {
const listener = mockAppApi.listeners.get(type)
if (!listener) throw new Error(`Missing listener for ${type}`)
listener(new CustomEvent(type, { detail }))
}
describe('useManagerQueue', () => {
let taskHistory: Ref<ManagerTaskHistory>
@@ -44,10 +92,12 @@ describe('useManagerQueue', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppApi.listeners.clear()
})
afterEach(() => {
vi.clearAllMocks()
mockAppApi.listeners.clear()
})
describe('initialization', () => {
@@ -234,5 +284,82 @@ describe('useManagerQueue', () => {
// installedPacks should remain unchanged
expect(installedPacks.value).toEqual({})
})
it('updates task state from task started websocket events', () => {
const queue = createManagerQueue()
dispatchManagerEvent('cm-task-started', {
state: createTaskState({
running_queue: [createQueueTask('running-task')],
pending_queue: [createQueueTask('other-client-task', 'other-client')]
})
})
expect(taskQueue.value.running_queue).toEqual([
createQueueTask('running-task')
])
expect(taskQueue.value.pending_queue).toEqual([])
expect(queue.currentQueueLength.value).toBe(1)
expect(queue.isProcessing.value).toBe(true)
expect(queue.allTasksDone.value).toBe(false)
})
it('updates task state from task completed websocket events', () => {
const queue = createManagerQueue()
dispatchManagerEvent('cm-task-completed', {
state: createTaskState({
history: {
completed: {
ui_id: 'completed',
client_id: 'test-client-id',
kind: 'install',
timestamp: '2024-01-01T00:00:00Z',
result: 'success'
}
}
})
})
expect(queue.historyCount.value).toBe(1)
expect(taskHistory.value).toHaveProperty('completed')
})
it('ignores malformed websocket events', () => {
const queue = createManagerQueue()
dispatchManagerEvent('cm-task-started', {
state: undefined
})
dispatchManagerEvent('cm-task-completed', {})
expect(queue.currentQueueLength.value).toBe(0)
expect(queue.historyCount.value).toBe(0)
})
it('removes websocket listeners and resets local flags on cleanup', () => {
const queue = createManagerQueue()
queue.isLoading.value = true
queue.updateTaskState(
createTaskState({
running_queue: [createQueueTask('running-task')]
})
)
queue.cleanup()
expect(queue.isLoading.value).toBe(false)
expect(queue.isProcessing.value).toBe(false)
expect(mockAppApi.removeEventListener).toHaveBeenCalledWith(
'cm-task-completed',
expect.any(Function),
undefined
)
expect(mockAppApi.removeEventListener).toHaveBeenCalledWith(
'cm-task-started',
expect.any(Function),
undefined
)
})
})
})

View File

@@ -9,6 +9,7 @@ import {
__resetIncompatibleToastGuard,
useManagerState
} from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Mock dependencies that are not stores
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
@@ -31,24 +32,38 @@ vi.mock('@/composables/useFeatureFlags', () => {
}
})
const {
commandExecuteMock,
managerDialogHideMock,
managerDialogShowMock,
settingsHideMock,
settingsShowAboutMock,
settingsShowMock,
toastAddMock
} = vi.hoisted(() => ({
commandExecuteMock: vi.fn(),
managerDialogHideMock: vi.fn(),
managerDialogShowMock: vi.fn(),
settingsHideMock: vi.fn(),
settingsShowAboutMock: vi.fn(),
settingsShowMock: vi.fn(),
toastAddMock: vi.fn()
}))
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: vi.fn(),
hide: vi.fn(),
showAbout: vi.fn()
show: settingsShowMock,
hide: settingsHideMock,
showAbout: settingsShowAboutMock
}))
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => ({
execute: vi.fn()
execute: commandExecuteMock
}))
}))
const { toastAddMock } = vi.hoisted(() => ({
toastAddMock: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: toastAddMock
@@ -56,12 +71,10 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerDialog', () => {
const show = vi.fn()
const hide = vi.fn()
return {
useManagerDialog: vi.fn(() => ({
show,
hide
show: managerDialogShowMock,
hide: managerDialogHideMock
}))
}
})
@@ -84,6 +97,17 @@ const systemStatsFixture = (argv: string[]) => ({
devices: []
})
const enabledManagerStats = () =>
systemStatsFixture(['python', 'main.py', '--enable-manager'])
const legacyManagerStats = () =>
systemStatsFixture([
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
])
/**
* Mocks the two server feature flags queried by useManagerState.
* `supports_v4` → `extension.manager.supports_v4`
@@ -138,12 +162,7 @@ describe('useManagerState', () => {
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
systemStatsStore.$patch({
systemStats: systemStatsFixture([
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
]),
systemStats: legacyManagerStats(),
isInitialized: true
})
@@ -237,12 +256,28 @@ describe('useManagerState', () => {
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
})
it('should disable manager for unexpected server support flag values', () => {
systemStatsStore.$patch({
systemStats: systemStatsFixture([
'python',
'main.py',
'--enable-manager'
]),
isInitialized: true
})
vi.mocked(api.getServerFeature).mockImplementation((name: string) => {
if (name === 'extension.manager.supports_v4') return 'unexpected'
if (name === 'extension.manager.supports_csrf_post') return true
return undefined
})
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
})
})
describe('INCOMPATIBLE state (missing supports_csrf_post)', () => {
const enabledManagerStats = () =>
systemStatsFixture(['python', 'main.py', '--enable-manager'])
it('returns INCOMPATIBLE when server supports v4 but csrf_post is false', () => {
systemStatsStore.$patch({
systemStats: enabledManagerStats(),
@@ -369,9 +404,6 @@ describe('useManagerState', () => {
})
describe('helper properties', () => {
const enabledManagerStats = () =>
systemStatsFixture(['python', 'main.py', '--enable-manager'])
it('isManagerEnabled should return true when state is not DISABLED / INCOMPATIBLE', () => {
systemStatsStore.$patch({
systemStats: enabledManagerStats(),
@@ -412,12 +444,7 @@ describe('useManagerState', () => {
it('isLegacyManagerUI should return true when state is LEGACY_UI', () => {
systemStatsStore.$patch({
systemStats: systemStatsFixture([
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
]),
systemStats: legacyManagerStats(),
isInitialized: true
})
@@ -453,4 +480,119 @@ describe('useManagerState', () => {
expect(managerState.shouldShowManagerButtons.value).toBe(true)
})
})
describe('openManager', () => {
it('opens extension settings when manager is disabled', async () => {
systemStatsStore.$patch({
systemStats: systemStatsFixture(['python', 'main.py']),
isInitialized: true
})
const managerState = useManagerState()
await managerState.openManager()
expect(settingsShowMock).toHaveBeenCalledWith('extension')
})
it('executes the default legacy manager command', async () => {
systemStatsStore.$patch({
systemStats: legacyManagerStats(),
isInitialized: true
})
const managerState = useManagerState()
await managerState.openManager()
expect(commandExecuteMock).toHaveBeenCalledWith(
'Comfy.Manager.Menu.ToggleVisibility'
)
})
it('executes a custom legacy manager command', async () => {
systemStatsStore.$patch({
systemStats: legacyManagerStats(),
isInitialized: true
})
const managerState = useManagerState()
await managerState.openManager({ legacyCommand: 'Custom.Manager.Open' })
expect(commandExecuteMock).toHaveBeenCalledWith('Custom.Manager.Open')
})
it('shows a toast when the legacy manager command is unavailable', async () => {
commandExecuteMock.mockRejectedValueOnce(new Error('missing command'))
systemStatsStore.$patch({
systemStats: legacyManagerStats(),
isInitialized: true
})
const managerState = useManagerState()
await managerState.openManager()
expect(toastAddMock).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'manager.legacyMenuNotAvailable'
})
expect(settingsShowMock).not.toHaveBeenCalled()
})
it('falls back to extension settings when legacy errors suppress the toast', async () => {
commandExecuteMock.mockRejectedValueOnce(new Error('missing command'))
systemStatsStore.$patch({
systemStats: legacyManagerStats(),
isInitialized: true
})
const managerState = useManagerState()
await managerState.openManager({ showToastOnLegacyError: false })
expect(toastAddMock).not.toHaveBeenCalled()
expect(settingsShowMock).toHaveBeenCalledWith('extension')
})
it('opens the new manager dialog with initial routing options', async () => {
systemStatsStore.$patch({
systemStats: enabledManagerStats(),
isInitialized: true
})
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
mockServerFeatures({ supports_v4: true, supports_csrf_post: true })
const managerState = useManagerState()
await managerState.openManager({
initialTab: ManagerTab.AllInstalled,
initialPackId: 'pack-1'
})
expect(managerDialogShowMock).toHaveBeenCalledWith(
ManagerTab.AllInstalled,
'pack-1'
)
})
it('shows a legacy-only error instead of opening the new manager', async () => {
systemStatsStore.$patch({
systemStats: enabledManagerStats(),
isInitialized: true
})
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
mockServerFeatures({ supports_v4: true, supports_csrf_post: true })
const managerState = useManagerState()
await managerState.openManager({ isLegacyOnly: true })
expect(toastAddMock).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'manager.legacyMenuNotAvailable'
})
expect(managerDialogShowMock).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,170 @@
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SortableField } from '@/types/searchServiceTypes'
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
const mockSearchGateway = vi.hoisted(() => ({
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(
(pack: Record<string, unknown>, field: string) => pack[field]
),
getSortableFields: vi.fn((): SortableField[] => [])
}))
vi.mock('@vueuse/core', async () => {
const vue = await import('vue')
return {
watchDebounced: (
source: unknown,
callback: () => void,
options?: { immediate?: boolean }
) => {
if (options?.immediate) callback()
return vue.watch(source as Parameters<typeof vue.watch>[0], callback)
}
}
})
vi.mock('@/services/gateway/registrySearchGateway', () => ({
useRegistrySearchGateway: () => mockSearchGateway
}))
function pack(name: string, downloads = 0) {
return { id: name, name, downloads }
}
async function flushSearch() {
await nextTick()
await Promise.resolve()
}
describe('useRegistrySearch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSearchGateway.searchPacks.mockResolvedValue({
nodePacks: [],
querySuggestions: []
})
mockSearchGateway.getSortableFields.mockReturnValue([])
})
it('runs the initial pack search with default paging and attributes', async () => {
mockSearchGateway.searchPacks.mockResolvedValueOnce({
nodePacks: [pack('alpha')],
querySuggestions: [{ query: 'alpha' }]
})
const search = useRegistrySearch({ initialSearchQuery: 'alp' })
await flushSearch()
expect(mockSearchGateway.searchPacks).toHaveBeenCalledWith('alp', {
pageSize: search.pageSize.value,
pageNumber: 0,
restrictSearchableAttributes: ['name', 'description']
})
expect(search.searchResults.value).toEqual([pack('alpha')])
expect(search.suggestions.value).toEqual([{ query: 'alpha' }])
expect(search.isLoading.value).toBe(false)
})
it('uses node-search attributes when search mode is nodes', async () => {
const search = useRegistrySearch({ initialSearchMode: 'nodes' })
await flushSearch()
expect(mockSearchGateway.searchPacks).toHaveBeenCalledWith('', {
pageSize: search.pageSize.value,
pageNumber: 0,
restrictSearchableAttributes: ['comfy_nodes']
})
})
it('sorts manually when a non-default sort field is selected', async () => {
mockSearchGateway.getSortableFields.mockReturnValue([
{ id: 'name', label: 'Name', direction: 'asc' }
])
mockSearchGateway.searchPacks.mockResolvedValueOnce({
nodePacks: [pack('zeta'), pack('alpha')],
querySuggestions: []
})
const search = useRegistrySearch({ initialSortField: 'name' })
await flushSearch()
expect(search.searchResults.value.map((item) => item.name)).toEqual([
'alpha',
'zeta'
])
})
it('appends results when loading later pages', async () => {
mockSearchGateway.searchPacks
.mockResolvedValueOnce({
nodePacks: [pack('first')],
querySuggestions: []
})
.mockResolvedValueOnce({
nodePacks: [pack('second')],
querySuggestions: []
})
const search = useRegistrySearch()
await flushSearch()
search.pageNumber.value = 1
await flushSearch()
expect(search.searchResults.value.map((item) => item.name)).toEqual([
'first',
'second'
])
expect(mockSearchGateway.searchPacks).toHaveBeenLastCalledWith('', {
pageSize: search.pageSize.value,
pageNumber: 1,
restrictSearchableAttributes: ['name', 'description']
})
})
it('resets to the first page when sort field changes', async () => {
mockSearchGateway.searchPacks
.mockResolvedValueOnce({
nodePacks: [pack('first')],
querySuggestions: []
})
.mockResolvedValueOnce({
nodePacks: [pack('second')],
querySuggestions: []
})
.mockResolvedValueOnce({
nodePacks: [pack('resorted')],
querySuggestions: []
})
const search = useRegistrySearch()
await flushSearch()
search.pageNumber.value = 1
await flushSearch()
expect(search.pageNumber.value).toBe(1)
search.sortField.value = 'name'
await flushSearch()
expect(search.pageNumber.value).toBe(0)
expect(search.searchResults.value).toEqual([pack('resorted')])
})
it('exposes sort options and clear cache from the gateway', () => {
const fields: SortableField[] = [
{ id: 'name', label: 'Name', direction: 'asc' }
]
mockSearchGateway.getSortableFields.mockReturnValue(fields)
const search = useRegistrySearch()
search.clearCache()
expect(search.sortOptions.value).toBe(fields)
expect(mockSearchGateway.clearSearchCache).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,272 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
const mockClient = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn()
}))
const mockAxios = vi.hoisted(() => ({
create: vi.fn(() => mockClient),
isAxiosError: vi.fn(() => false)
}))
const mockApi = vi.hoisted(() => ({
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
clientId: 'client-1' as string | null,
initialClientId: 'initial-client'
}))
const mockManagerState = vi.hoisted(() => ({
isNewManagerUI: { value: true }
}))
const mockIsAbortError = vi.hoisted(() => vi.fn(() => false))
vi.mock('axios', () => ({
default: mockAxios
}))
vi.mock('uuid', () => ({
v4: () => 'generated-ui-id'
}))
vi.mock('@/scripts/api', () => ({
api: mockApi
}))
vi.mock('@/utils/typeGuardUtil', () => ({
isAbortError: mockIsAbortError
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => mockManagerState
}))
function axiosError(status: number, message?: string): unknown {
return {
response: {
status,
data: message ? { message } : undefined
}
}
}
describe('useComfyManagerService', () => {
beforeEach(() => {
mockClient.get.mockReset()
mockClient.post.mockReset()
mockAxios.isAxiosError.mockReset()
mockAxios.isAxiosError.mockReturnValue(false)
mockApi.apiURL.mockClear()
mockManagerState.isNewManagerUI.value = true
mockApi.clientId = 'client-1'
mockApi.initialClientId = 'initial-client'
mockIsAbortError.mockReturnValue(false)
})
it('blocks requests when the new manager UI is unavailable', async () => {
mockManagerState.isNewManagerUI.value = false
const service = useComfyManagerService()
const result = await service.listInstalledPacks()
expect(result).toBeNull()
expect(service.error.value).toBe(
'Manager service is not available in current mode'
)
expect(mockClient.get).not.toHaveBeenCalled()
})
it('fetches installed packs and tracks loading state', async () => {
mockClient.get.mockResolvedValueOnce({ data: { packs: [] } })
const service = useComfyManagerService()
const promise = service.listInstalledPacks()
expect(service.isLoading.value).toBe(true)
await expect(promise).resolves.toEqual({ packs: [] })
expect(service.isLoading.value).toBe(false)
expect(service.error.value).toBeNull()
expect(mockClient.get).toHaveBeenCalledWith('customnode/installed', {
signal: undefined
})
})
it('passes queue status query params only when a client id is provided', async () => {
mockClient.get
.mockResolvedValueOnce({ data: { running: true } })
.mockResolvedValueOnce({ data: { running: false } })
const service = useComfyManagerService()
await service.getQueueStatus('client-a')
await service.getQueueStatus()
expect(mockClient.get).toHaveBeenNthCalledWith(1, 'manager/queue/status', {
params: { client_id: 'client-a' },
signal: undefined
})
expect(mockClient.get).toHaveBeenNthCalledWith(2, 'manager/queue/status', {
params: undefined,
signal: undefined
})
})
it('returns an empty bulk import result without making a request when no ids or urls are provided', async () => {
const service = useComfyManagerService()
await expect(service.getImportFailInfoBulk()).resolves.toEqual({})
expect(mockClient.post).not.toHaveBeenCalled()
})
it('posts bulk import failure requests when ids are provided', async () => {
mockClient.post.mockResolvedValueOnce({ data: { failed: [] } })
const service = useComfyManagerService()
const params = { cnr_ids: ['pack'] } as Parameters<
typeof service.getImportFailInfoBulk
>[0]
await expect(service.getImportFailInfoBulk(params)).resolves.toEqual({
failed: []
})
expect(mockClient.post).toHaveBeenCalledWith(
'customnode/import_fail_info_bulk',
params,
{ signal: undefined }
)
})
it('queues install tasks with generated ids and starts the queue', async () => {
mockClient.post
.mockResolvedValueOnce({ data: null })
.mockResolvedValueOnce({ data: null })
const service = useComfyManagerService()
const params = { id: 'pack-id' } as Parameters<
typeof service.installPack
>[0]
await expect(service.installPack(params)).resolves.toBeNull()
expect(mockClient.post).toHaveBeenNthCalledWith(
1,
'manager/queue/task',
{
kind: 'install',
params,
ui_id: 'generated-ui-id',
client_id: 'client-1'
},
{ signal: undefined }
)
expect(mockClient.post).toHaveBeenNthCalledWith(
2,
'manager/queue/start',
null,
{ signal: undefined }
)
})
it('uses initial client id when queueing and the current client id is absent', async () => {
mockApi.clientId = null
mockClient.post
.mockResolvedValueOnce({ data: null })
.mockResolvedValueOnce({ data: null })
const service = useComfyManagerService()
const params: Parameters<typeof service.updatePack>[0] = {
node_name: 'pack-id'
}
await service.updatePack(params, 'ui-id')
expect(mockClient.post).toHaveBeenNthCalledWith(
1,
'manager/queue/task',
expect.objectContaining({
kind: 'update',
ui_id: 'ui-id',
client_id: 'initial-client'
}),
{ signal: undefined }
)
})
it('posts update all requests with query params and starts the queue', async () => {
mockClient.post
.mockResolvedValueOnce({ data: null })
.mockResolvedValueOnce({ data: null })
const service = useComfyManagerService()
await service.updateAllPacks({ mode: 'remote' }, 'ui-id')
expect(mockClient.post).toHaveBeenNthCalledWith(
1,
'manager/queue/update_all',
null,
{
params: {
mode: 'remote',
client_id: 'client-1',
ui_id: 'ui-id'
},
signal: undefined
}
)
})
it('maps route-specific axios errors', async () => {
mockAxios.isAxiosError.mockReturnValueOnce(true)
mockClient.post.mockRejectedValueOnce(axiosError(403))
const service = useComfyManagerService()
const params = { id: 'pack-id' } as Parameters<
typeof service.installPack
>[0]
await expect(service.installPack(params)).resolves.toBeNull()
expect(service.error.value).toBe(
'Forbidden: A security error has occurred. Please check the terminal logs'
)
})
it('maps manager connectivity axios errors', async () => {
mockAxios.isAxiosError.mockReturnValueOnce(true)
mockClient.get.mockRejectedValueOnce(axiosError(404))
const service = useComfyManagerService()
await service.listInstalledPacks()
expect(service.error.value).toBe('Could not connect to ComfyUI-Manager')
})
it('uses response messages from generic axios errors', async () => {
mockAxios.isAxiosError.mockReturnValueOnce(true)
mockClient.get.mockRejectedValueOnce(axiosError(500, 'server exploded'))
const service = useComfyManagerService()
await service.getTaskHistory()
expect(service.error.value).toBe('server exploded')
})
it('uses thrown error messages for non-axios errors', async () => {
mockClient.get.mockRejectedValueOnce(new Error('network down'))
const service = useComfyManagerService()
await service.getImportFailInfo()
expect(service.error.value).toBe(
'Fetching import failure information failed: network down'
)
})
it('does not set an error for aborted requests', async () => {
mockIsAbortError.mockReturnValueOnce(true)
mockClient.get.mockRejectedValueOnce(new Error('aborted'))
const service = useComfyManagerService()
await service.isLegacyManagerUI()
expect(service.error.value).toBeNull()
})
})

View File

@@ -13,6 +13,24 @@ type ManagerChannel = ManagerComponents['schemas']['ManagerChannel']
type ManagerDatabaseSource =
ManagerComponents['schemas']['ManagerDatabaseSource']
type ManagerPackInstalled = ManagerComponents['schemas']['ManagerPackInstalled']
type TaskHistoryItem = ManagerComponents['schemas']['TaskHistoryItem']
const { mockApp, mockClientId } = vi.hoisted(() => ({
mockApp: { api: new EventTarget() },
mockClientId: { value: 'client-id' }
}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('@/scripts/api', () => ({
api: {
get clientId() {
return mockClientId.value
}
}
}))
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: vi.fn()
@@ -44,17 +62,6 @@ vi.mock('@/composables/useServerLogs', () => ({
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key) => key)
}),
createI18n: vi.fn(() => ({
global: {
t: vi.fn((key) => key)
}
}))
}))
interface EnabledDisabledTestCase {
desc: string
installed: Record<string, ManagerPackInstalled>
@@ -79,6 +86,8 @@ describe('useComfyManagerStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
mockApp.api = new EventTarget()
mockClientId.value = 'client-id'
mockManagerService = {
isLoading: ref(false),
error: ref(null),
@@ -501,4 +510,229 @@ describe('useComfyManagerStore', () => {
expect(store.isPackInstalled('disabled-pack')).toBe(true)
})
})
describe('task state', () => {
it('cleans installing state when manager reports task completion', async () => {
const store = useComfyManagerStore()
await store.installPack.call({
id: 'event-pack',
repository: 'https://github.com/test/event-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
const taskId = vi.mocked(mockManagerService.installPack).mock.calls[0][1]
mockApp.api.dispatchEvent(
new CustomEvent('cm-task-completed', { detail: {} })
)
mockApp.api.dispatchEvent(
new CustomEvent('cm-task-completed', {
detail: { ui_id: 'unknown-task' }
})
)
expect(store.isPackInstalling('event-pack')).toBe(true)
mockApp.api.dispatchEvent(
new CustomEvent('cm-task-completed', { detail: { ui_id: taskId } })
)
expect(store.isPackInstalling('event-pack')).toBe(false)
})
it('partitions task ids and logs by task status', async () => {
const store = useComfyManagerStore()
store.taskLogs = [
{ taskName: 'Success', taskId: 'success', logs: [] },
{ taskName: 'Failed', taskId: 'failed', logs: [] },
{ taskName: 'Unknown', taskId: 'unknown', logs: [] }
]
store.taskHistory = {
success: {
ui_id: 'success',
status: { status_str: 'success' }
} as TaskHistoryItem,
failed: {
ui_id: 'failed',
status: { status_str: 'error' }
} as TaskHistoryItem,
unknown: {
ui_id: 'unknown'
} as TaskHistoryItem
}
await nextTick()
expect(store.succeededTasksIds).toEqual(['success'])
expect(store.failedTasksIds).toEqual(['failed', 'unknown'])
expect(store.succeededTasksLogs.map((log) => log.taskId)).toEqual([
'success'
])
expect(store.failedTasksLogs.map((log) => log.taskId)).toEqual([
'failed',
'unknown'
])
})
it('records client-side task errors with fallback client ids and messages', async () => {
mockClientId.value = ''
vi.mocked(mockManagerService.installPack).mockRejectedValueOnce(
new Error('install failed')
)
const store = useComfyManagerStore()
await store.installPack.call({
id: 'error-pack',
repository: 'https://github.com/test/error-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
const taskId = vi.mocked(mockManagerService.installPack).mock.calls[0][1]
if (!taskId) throw new Error('expected install task id to be generated')
expect(store.taskHistory[taskId].client_id).toBe('unknown')
expect(store.taskHistory[taskId].status?.messages).toEqual([
'install failed'
])
expect(store.isProcessingTasks).toBe(false)
})
it('records string task errors', async () => {
vi.mocked(mockManagerService.installPack).mockRejectedValueOnce(
'install failed'
)
const store = useComfyManagerStore()
await store.installPack.call({
id: 'string-error-pack',
repository: 'https://github.com/test/string-error-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
const taskId = vi.mocked(mockManagerService.installPack).mock.calls[0][1]
if (!taskId) throw new Error('expected install task id to be generated')
expect(store.taskHistory[taskId].status?.messages).toEqual([
'install failed'
])
})
it('resets task state and clears logs', async () => {
const store = useComfyManagerStore()
await store.installPack.call({
id: 'reset-pack',
repository: 'https://github.com/test/reset-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
store.clearLogs()
store.taskHistory = {
success: {
ui_id: 'success',
status: { status_str: 'success' }
} as TaskHistoryItem
}
await nextTick()
store.resetTaskState()
expect(store.taskLogs).toEqual([])
expect(store.taskHistory).toEqual({})
expect(store.succeededTasksIds).toEqual([])
expect(store.isPackInstalling('reset-pack')).toBe(false)
})
})
describe('edge cases', () => {
it('ignores installed entries without pack ids', async () => {
const store = useComfyManagerStore()
await triggerPacksChange(
{
nameless: {
enabled: true,
cnr_id: undefined,
aux_id: undefined,
ver: '1.0.0'
}
},
store
)
expect(store.installedPacksIds.size).toBe(0)
expect(store.isPackInstalled('nameless')).toBe(false)
})
it('marks the store fresh when refresh returns no packs', async () => {
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(null)
const store = useComfyManagerStore()
await store.refreshInstalledList()
expect(store.installedPacks).toEqual({})
})
it('ignores install requests without ids', async () => {
const store = useComfyManagerStore()
await store.installPack.call({
id: '',
repository: 'https://github.com/test/missing-id',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
expect(mockManagerService.installPack).not.toHaveBeenCalled()
})
it('handles installed package install actions for version changes and enabling', async () => {
const store = useComfyManagerStore()
await triggerPacksChange(
{
'change-pack': {
enabled: true,
cnr_id: 'change-pack',
aux_id: undefined,
ver: '1.0.0'
},
'enable-pack': {
enabled: true,
cnr_id: 'enable-pack',
aux_id: undefined,
ver: 'latest'
}
},
store
)
await store.installPack.call({
id: 'change-pack',
repository: 'https://github.com/test/change-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: '2.0.0',
version: '2.0.0'
})
await store.installPack.call({
id: 'enable-pack',
repository: 'https://github.com/test/enable-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
expect(mockManagerService.installPack).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -329,7 +329,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
await enqueueTaskWithLogs(task, t('g.enabling', { id: params.id }))
}
const getInstalledPackVersion = (packId: NodePackId) => {
const getInstalledPackVersion = (packId: NodePackId): string | undefined => {
const pack = installedPacks.value[packId]
return pack?.ver
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { extractCustomNodeName, getNodeHelpBaseUrl } from './nodeHelpUtil'
function nodeDef(name: string, python_module: string) {
return { name, python_module }
}
describe('nodeHelpUtil', () => {
it('extracts normalized custom node package names', () => {
expect(
extractCustomNodeName('custom_nodes.ComfyUI-TestPack@1_2_3.nodes')
).toBe('ComfyUI-TestPack')
expect(extractCustomNodeName('nodes')).toBeNull()
expect(extractCustomNodeName(undefined)).toBeNull()
})
it('returns base URLs for blueprint, custom, and core nodes', () => {
expect(
getNodeHelpBaseUrl(nodeDef('SubgraphBlueprint.Test', 'blueprint'))
).toBe('')
expect(
getNodeHelpBaseUrl(nodeDef('CustomNode', 'custom_nodes.TestPack.nodes'))
).toBe('/extensions/TestPack/docs/')
expect(getNodeHelpBaseUrl(nodeDef('LoadImage', 'nodes'))).toBe(
'/docs/LoadImage/'
)
expect(getNodeHelpBaseUrl(nodeDef('UnknownNode', 'custom_nodes'))).toBe(
'/docs/UnknownNode/'
)
})
})

View File

@@ -1,4 +1,3 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import { normalizePackId } from '@/utils/packUtils'
@@ -7,13 +6,15 @@ export function extractCustomNodeName(
): string | null {
const modules = pythonModule?.split('.') || []
if (modules.length >= 2 && modules[0] === 'custom_nodes') {
// Use normalizePackId to remove version suffix
return normalizePackId(modules[1])
}
return null
}
export function getNodeHelpBaseUrl(node: ComfyNodeDefImpl): string {
export function getNodeHelpBaseUrl(node: {
name: string
python_module: string
}): string {
const nodeSource = getNodeSource(node.python_module)
if (nodeSource.type === NodeSourceType.Blueprint) {
return ''