Compare commits

..

4 Commits

Author SHA1 Message Date
Wei Hai
744b105355 fix(billing): restore preview test compatibility after SubscriptionTermsNote refactor
SubscriptionTermsNote gained a <script setup> that imports useSettingsDialog,
pulling in dialogService -> @/i18n -> createI18n from vue-i18n. Two existing
tests mocked vue-i18n with only useI18n, so createI18n was undefined at module
load time, collapsing both test suites.

Use importOriginal in both test mocks so all real vue-i18n exports (including
createI18n) are preserved while only useI18n is overridden. Also stub
SubscriptionTermsNote in SubscriptionAddPaymentPreviewWorkspace.test.ts to
prevent the component's setup() from calling useSettingsDialog (which requires
an active Pinia instance not set up in that test).

Also simplify methodLabel computed in SpendLimitDialogContent.vue to look up
the single needed i18n key instead of eagerly translating all four method
labels on every recompute.
2026-07-01 00:34:04 -07:00
Wei Hai
8f2a24a73d fix(billing): address review findings on payment-method collection UX
- Validate currency code with /^[A-Z]{3}$/ before passing to
  toLocaleString, falling back to USD for empty/invalid values
- Gate owed-balance notice CTAs (terms note + action buttons) behind
  permissions.canTopUp so non-owner members see the balance text but
  not owner-only billing actions
- Add neutral message branch for null paymentMethodCapability (status
  still loading or unavailable) so users are never stuck with no
  feedback
- Add noopener,noreferrer to window.open calls in CreditsTile and
  SpendLimitDialogContent to prevent reverse-tabnabbing
- Fix SpendLimitDialogContent title/ctaLabel to branch on scenario as
  well as capability, making visible label, aria-label, and action
  self-consistent for reusable+limit_reached
- Replace hardcoded English METHOD_LABELS with vue-i18n keys under
  billing.spendLimit.methodLabels
- Import PaymentMethodCapability from workspaceApi instead of
  duplicating the union in SpendLimitDialogContent and dialogService
- Make the pay_owed processing-toast branch explicit in
  billingOperationStore to avoid a silent implicit else
- Add tests: currency fallback, canTopUp gate, null-capability branch
2026-07-01 00:05:04 -07:00
Wei Hai
a222ad0ba5 feat: add payment method consent disclosure and owed-balance pay-now flow
- Add SubscriptionTermsNote (context="payment_method") above the
  add-payment-method CTA in SpendLimitDialogContent (Variants A and B)
  and above the CTA in CreditsTile for none/one_time_only capability

- Add Pay now button to CreditsTile owed-balance notice when
  paymentMethodCapability=reusable and the settle endpoint flag is on;
  passes a crypto.randomUUID() idempotency key to settleOwedBalance()

- Guard handlePayNow with a local ref to prevent double-submission
  before the billing-operation store updates; focus management moves to
  the refresh button on success or pay-now button on failure/cancel

- Validate Stripe redirect URL (hostname must end with .stripe.com)
  before window.open() in both CreditsTile and SpendLimitDialogContent;
  toast + abort on mismatch

- Null-check the window.open() return value and toast a "popup blocked"
  warning when the browser suppresses the popup

- Widen showSpendLimitDialog options with capabilityError?: boolean
  and fix ctaAriaLabel to be consistent with ctaLabel when capabilityError

- Add billing.spendLimit.defaultMethod i18n key used as the fallback
  method label in SpendLimitDialogContent

- Fix test suite: mock useBillingOperationStore with a plain getter for
  isPayingOwed (not a ComputedRef) to match Pinia's auto-unwrap
  behaviour; add mocks for useFeatureFlags, workspaceApi, and
  useToastStore; add 9 owed-balance notice test cases
2026-06-30 23:37:33 -07:00
Wei Hai
2fae5a2089 feat(billing): method-aware payment-method collection UX
Add three surfaces for off-session payment collection:
- Spend-limit dialog (SpendLimitDialogContent) with method-aware variants
  driven by payment_method_capability; add-method routes to the setup flow,
  a failed auto-charge routes to the billing portal.
- Consent disclosure via a context prop on SubscriptionTermsNote.
- Owed-balance notice in CreditsTile with a capability-aware CTA, a
  settle-endpoint feature flag (default off -> read-only fallback), and a
  dedicated pay_owed billing operation type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:39:35 -07:00
93 changed files with 1295 additions and 4301 deletions

View File

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

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"
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
>
<slot />
</li>

View File

@@ -1,45 +0,0 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": {
"0": 64,
"1": 104
},
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"linearData": {
"inputs": [],
"outputs": ["9"]
}
},
"version": 0.4
}

View File

@@ -34,10 +34,6 @@ export class AppModeHelper {
public readonly outputPlaceholder: Locator
/** The linear-mode widget list container (visible in app mode). */
public readonly linearWidgets: Locator
/** The validation warning shown above the app mode run button. */
public readonly validationWarning: Locator
/** The action that opens graph mode errors from the validation warning. */
public readonly viewErrorsInGraphButton: Locator
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
@@ -96,19 +92,13 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
this.validationWarning = this.page.getByTestId(
TestIds.linear.validationWarning
)
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
TestIds.linear.viewErrorsInGraph
)
this.linearWidgets = this.page.getByTestId('linear-widgets')
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId(TestIds.linear.runButton)
.getByTestId('linear-run-button')
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(

View File

@@ -172,9 +172,6 @@ export const TestIds = {
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
runButton: 'linear-run-button',
validationWarning: 'linear-validation-warning',
viewErrorsInGraph: 'linear-view-errors',
widgetContainer: 'linear-widgets'
},
builder: {

View File

@@ -1,106 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const SAVE_IMAGE_NODE_ID = '9'
function buildSaveImageRequiredInputError(): NodeError {
return {
class_type: 'SaveImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: images',
details: '',
extra_info: { input_name: 'images' }
}
]
}
}
test.describe(
'App mode validation warning',
{ tag: ['@ui', '@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test('opens graph errors from the app mode validation warning', async ({
comfyPage
}) => {
await expect(comfyPage.appMode.validationWarning).toBeHidden()
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(appModeOverlay).toBeHidden()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.validationWarning).toContainText(
/Required input missing/i
)
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
await comfyPage.appMode.viewErrorsInGraphButton.click()
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeVisible()
})
test('keeps the app mode run button enabled when the warning is visible', async ({
comfyPage
}) => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.runButton).toBeEnabled()
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route(
'**/api/prompt',
async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
},
{ times: 1 }
)
await comfyPage.appMode.runButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
}
)

View File

@@ -1,6 +1,5 @@
import { expect } from '@playwright/test'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -16,10 +15,9 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
await expect
.poll(() =>
comfyPage.page.evaluate(
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
toLinkId(1)
)
comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
})
)
.toBe(1)
})

View File

@@ -3,7 +3,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Displays linear controls when app mode active', async ({
@@ -17,9 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.getByTestId(TestIds.linear.runButton)
).toBeVisible()
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
})
test('Workflow info section visible', async ({ comfyPage }) => {

View File

@@ -37,7 +37,7 @@
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="error-overlay-see-errors"
@click="viewErrorsInGraph"
@click="seeErrors"
>
{{
appMode
@@ -67,18 +67,31 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
const { appMode = false } = defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
function dismiss() {
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import type { ComputedRef, Ref } from 'vue'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
BillingStatus,
BillingSubscriptionStatus,
CreateTopupResponse,
CurrentTeamCreditStop,
PaymentMethodCapability,
Plan,
PreviewSubscribeOptions,
PreviewSubscribeResponse,
@@ -38,6 +38,7 @@ export interface BalanceInfo {
effectiveBalanceMicros?: number
prepaidBalanceMicros?: number
cloudCreditBalanceMicros?: number
pendingChargesMicros?: number
}
export interface BillingActions {
@@ -76,10 +77,9 @@ export interface BillingActions {
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog. Pass a reason so the paywall open and any
* downstream checkout stay attributed to the triggering product moment.
* Shows the subscription dialog.
*/
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
showSubscriptionDialog: () => void
}
export interface BillingState {
@@ -103,6 +103,10 @@ export interface BillingState {
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>
/** Workspace-only: what payment methods can be used for recurring charges. */
paymentMethodCapability: ComputedRef<PaymentMethodCapability | null>
/** Workspace-only: type slug of the default payment method on file. */
defaultPaymentMethodType: ComputedRef<string | null>
}
export interface BillingContext extends BillingState, BillingActions {

View File

@@ -7,7 +7,6 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
SubscribeOptions
@@ -159,6 +158,12 @@ function useBillingContextInternal(): BillingContext {
)
const tier = computed(() => toValue(activeContext.value.tier))
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
const paymentMethodCapability = computed(() =>
toValue(activeContext.value.paymentMethodCapability)
)
const defaultPaymentMethodType = computed(() =>
toValue(activeContext.value.defaultPaymentMethodType)
)
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
@@ -282,8 +287,8 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
return activeContext.value.showSubscriptionDialog(options)
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
}
return {
@@ -304,6 +309,8 @@ function useBillingContextInternal(): BillingContext {
subscriptionStatus,
tier,
renewalDate,
paymentMethodCapability,
defaultPaymentMethodType,
getMaxSeats,
initialize,

View File

@@ -2,7 +2,6 @@ import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingStatus,
BillingSubscriptionStatus,
@@ -92,6 +91,9 @@ export function useLegacyBilling(): BillingState & BillingActions {
const renewalDate = computed(
() => legacySubscriptionStatus.value?.renewal_date ?? null
)
// Payment method capability is workspace-only; legacy always reports null.
const paymentMethodCapability = computed(() => null)
const defaultPaymentMethodType = computed(() => null)
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
@@ -190,12 +192,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
legacyShowSubscriptionDialog()
}
}
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
legacyShowSubscriptionDialog(options)
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
}
return {
@@ -215,6 +217,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
subscriptionStatus,
tier,
renewalDate,
paymentMethodCapability,
defaultPaymentMethodType,
// Actions
initialize,

View File

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

View File

@@ -30,7 +30,8 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
SIGNUP_TURNSTILE = 'signup_turnstile'
SIGNUP_TURNSTILE = 'signup_turnstile',
SETTLE_ENDPOINT_ENABLED = 'settle_endpoint_enabled'
}
/**
@@ -181,6 +182,13 @@ export function useFeatureFlags() {
remoteConfig.value.signup_turnstile,
'off'
)
},
get settleEndpointEnabled() {
return resolveFlag(
ServerFeatureFlag.SETTLE_ENDPOINT_ENABLED,
undefined,
false
)
}
})

View File

@@ -1,105 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
const apiMock = vi.hoisted(() => ({
getSettings: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: apiMock
}))
const appMock = vi.hoisted(() => ({
ui: {
settings: {
dispatchChange: vi.fn()
}
},
rootGraph: {
events: new EventTarget(),
nodes: []
}
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
function createSelectedCanvas() {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
const node = new LGraphNode('Selected Node')
graph.add(node)
canvas.selectedItems.add(node)
node.selected = true
return { canvas, node }
}
describe('useViewErrorsInGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
apiMock.getSettings.mockResolvedValue({})
apiMock.storeSetting.mockResolvedValue(undefined)
apiMock.storeSettings.mockResolvedValue(undefined)
})
it('opens graph errors and clears app-mode error UI state', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
it('opens graph errors when the canvas is not initialized', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.canvas = null
executionErrorStore.showErrorOverlay()
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
})

View File

@@ -1,22 +0,0 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
export function useViewErrorsInGraph() {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
function viewErrorsInGraph() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
return { viewErrorsInGraph }
}

View File

@@ -2506,7 +2506,11 @@
"topupFailed": "Top-up failed",
"topupTimeout": "Top-up verification timed out",
"cancelFailed": "Failed to cancel subscription",
"cancelTimeout": "Subscription cancellation timed out"
"cancelTimeout": "Subscription cancellation timed out",
"payOwedProcessing": "Processing payment…",
"payOwedSuccess": "Payment processed successfully",
"payOwedFailed": "Payment failed",
"payOwedTimeout": "Payment verification timed out"
},
"subscription": {
"plansForWorkspace": "Plans for {workspace}",
@@ -4489,5 +4493,37 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"billing": {
"spendLimit": {
"addPaymentMethodTitle": "Add a payment method",
"paymentFailedTitle": "Your automatic payment failed",
"oneTimeOnlyInfo": "{method} can't be used for automatic top-ups — add a card, bank account, or Link",
"addPaymentMethodCta": "Add a payment method",
"updatePaymentMethodCta": "Update payment method",
"orBuyManually": "Or buy credits manually",
"capabilityError": "Unable to load payment method status. You can add a payment method below.",
"defaultMethod": "Your current payment method",
"methodLabels": {
"alipay": "Alipay",
"card": "Your card",
"us_bank_account": "Your bank account",
"link": "Link"
}
},
"owedBalance": {
"title": "Outstanding balance: {amount}",
"addPaymentMethod": "Add a payment method",
"addCardOrBank": "Add a card or bank account",
"oneTimeOnlyHint": "Your current method can't be used to settle a balance — add a card, bank account, or Link",
"payNow": "Pay now",
"processing": "Processing payment…",
"chargeAutomatic": "A charge will process automatically.",
"unknownCapability": "Payment method status unavailable. Refresh to try again."
},
"consent": {
"paymentMethodBody": "By adding a payment method, you authorize Comfy Org to automatically charge it — without further action from you at the time — for unpaid balances and usage overages, in the amount you owe at billing time. You can remove it at any time in {settingsLink} (an active subscription or unpaid balance may need to be resolved first).",
"settingsLink": "Account Settings → Credits"
}
}
}

View File

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

View File

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

View File

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

View File

@@ -10,12 +10,16 @@ import type { CurrentTeamCreditStop } from '@/platform/workspace/api/workspaceAp
type Balance = Pick<
BalanceInfo,
'amountMicros' | 'cloudCreditBalanceMicros' | 'prepaidBalanceMicros'
>
| 'amountMicros'
| 'cloudCreditBalanceMicros'
| 'prepaidBalanceMicros'
| 'pendingChargesMicros'
> & { currency?: string }
type Subscription = Pick<SubscriptionInfo, 'duration' | 'renewalDate'> & {
tier: SubscriptionInfo['tier'] | 'TEAM'
}
type TeamStop = CurrentTeamCreditStop
type PaymentMethodCapability = 'none' | 'one_time_only' | 'reusable'
const state = vi.hoisted(() => ({
balance: null as Balance | null,
@@ -25,12 +29,20 @@ const state = vi.hoisted(() => ({
currentTeamCreditStop: null as TeamStop | null,
isLoading: false,
canTopUp: true,
paymentMethodCapability: null as PaymentMethodCapability | null,
settleEndpointEnabled: false,
isPayingOwed: false,
fetchBalance: vi.fn(),
fetchStatus: vi.fn(),
showPricingTable: vi.fn(),
showTopUpCreditsDialog: vi.fn(),
trackAddApiCreditButtonClicked: vi.fn(),
toastErrorHandler: vi.fn()
toastErrorHandler: vi.fn(),
toastAdd: vi.fn(),
initiateAddPaymentMethod: vi.fn(),
settleOwedBalance: vi.fn(),
startOperation: vi.fn(),
clearOperation: vi.fn()
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -57,11 +69,46 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
isFreeTier: computed(() => state.isFreeTier),
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
isLoading: computed(() => state.isLoading),
paymentMethodCapability: computed(() => state.paymentMethodCapability),
fetchBalance: state.fetchBalance,
fetchStatus: state.fetchStatus
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get settleEndpointEnabled() {
return state.settleEndpointEnabled
}
}
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
initiateAddPaymentMethod: (...args: unknown[]) =>
state.initiateAddPaymentMethod(...args),
settleOwedBalance: (...args: unknown[]) => state.settleOwedBalance(...args)
}
}))
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({
get isPayingOwed() {
return state.isPayingOwed
},
startOperation: (...args: unknown[]) => state.startOperation(...args),
clearOperation: (...args: unknown[]) => state.clearOperation(...args)
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: (...args: unknown[]) => state.toastAdd(...args)
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({ canTopUp: state.canTopUp }))
@@ -92,6 +139,11 @@ const i18n = createI18n({
locale: 'en',
messages: {
en: {
g: {
error: 'Error',
warning: 'Warning',
unknownError: 'An unknown error occurred'
},
subscription: {
totalCredits: 'Total credits',
remaining: 'remaining',
@@ -116,7 +168,25 @@ const i18n = createI18n({
outOfCreditsTitleNoDate: "You're out of credits",
outOfCreditsDescription: 'Add more credits to continue generating.',
addCredits: 'Add credits',
upgradeToAddCredits: 'Upgrade to add credits'
upgradeToAddCredits: 'Upgrade to add credits',
preview: {
paymentPopupBlocked:
'Popup blocked. Please allow popups and try again.'
}
},
billing: {
owedBalance: {
title: 'Outstanding balance: {amount}',
addPaymentMethod: 'Add a payment method',
addCardOrBank: 'Add a card or bank account',
oneTimeOnlyHint:
"Your current method can't be used to settle a balance — add a card, bank account, or Link",
chargeAutomatic: 'A charge will process automatically.',
payNow: 'Pay now',
processing: 'Processing payment…',
unknownCapability:
'Payment method status unavailable. Refresh to try again.'
}
}
}
}
@@ -131,11 +201,17 @@ function renderTile(props: Record<string, unknown> = {}) {
stubs: {
Button: {
template:
'<button v-bind="$attrs" :data-variant="variant" :disabled="loading" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading'],
'<button v-bind="$attrs" :data-variant="variant" :disabled="disabled || loading" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading', 'disabled'],
emits: ['click']
},
Skeleton: { template: '<div role="status" aria-label="Loading"></div>' }
Skeleton: {
template: '<div role="status" aria-label="Loading"></div>'
},
SubscriptionTermsNote: {
template: '<p data-testid="terms-note" :data-context="context"></p>',
props: ['context']
}
}
}
})
@@ -165,6 +241,9 @@ describe('CreditsTile', () => {
state.currentTeamCreditStop = null
state.isLoading = false
state.canTopUp = true
state.paymentMethodCapability = null
state.settleEndpointEnabled = false
state.isPayingOwed = false
vi.clearAllMocks()
})
@@ -370,4 +449,138 @@ describe('CreditsTile', () => {
expect(state.toastErrorHandler).toHaveBeenCalledWith(failure)
)
})
describe('owed balance notice', () => {
it('hides the notice when pendingChargesMicros is null', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: undefined }
renderTile()
expect(screen.queryByRole('alert')).toBeNull()
})
it('hides the notice when pendingChargesMicros is zero', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 0 }
renderTile()
expect(screen.queryByRole('alert')).toBeNull()
})
it('hides the notice when pendingChargesMicros is negative', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: -100 }
renderTile()
expect(screen.queryByRole('alert')).toBeNull()
})
it('shows the notice with a formatted amount when pendingChargesMicros is positive', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 5_000_000 }
state.paymentMethodCapability = 'reusable'
renderTile()
const alert = screen.getByRole('alert')
expect(alert.textContent).toContain('$5.00')
})
it('shows "Add a payment method" CTA and terms note for capability=none', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'none'
renderTile()
expect(screen.getByText('Add a payment method')).toBeTruthy()
const termsNote = screen.getByTestId('terms-note')
expect(termsNote.dataset.context).toBe('payment_method')
expect(screen.queryByText('Pay now')).toBeNull()
})
it('shows "Add a card or bank account" CTA, hint, and terms note for capability=one_time_only', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'one_time_only'
renderTile()
expect(screen.getByText('Add a card or bank account')).toBeTruthy()
expect(
screen.getByText(
"Your current method can't be used to settle a balance — add a card, bank account, or Link"
)
).toBeTruthy()
const termsNote = screen.getByTestId('terms-note')
expect(termsNote.dataset.context).toBe('payment_method')
expect(screen.queryByText('Pay now')).toBeNull()
})
it('shows an auto-charge message and no CTA for capability=reusable when settle flag is off', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'reusable'
state.settleEndpointEnabled = false
renderTile()
expect(
screen.getByText('A charge will process automatically.')
).toBeTruthy()
expect(screen.queryByText('Pay now')).toBeNull()
expect(screen.queryByText('Add a payment method')).toBeNull()
expect(screen.queryByTestId('terms-note')).toBeNull()
})
it('shows "Pay now" CTA for capability=reusable when settle flag is on', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'reusable'
state.settleEndpointEnabled = true
const { container } = renderTile()
expect(container.textContent).toContain('Pay now')
expect(container.textContent).not.toContain('Add a payment method')
})
it('shows "Processing payment…" and disables the Pay now button while isPayingOwed', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'reusable'
state.settleEndpointEnabled = true
state.isPayingOwed = true
const { container } = renderTile()
expect(container.textContent).toContain('Processing payment…')
const btn = screen.getByRole('button', { name: /Processing payment/i })
expect(btn.getAttribute('disabled')).not.toBeNull()
})
it('falls back to USD when balance.currency is an empty string', () => {
activeProSubscription()
state.balance = {
amountMicros: 500,
pendingChargesMicros: 5_000_000,
currency: ''
}
state.paymentMethodCapability = 'reusable'
renderTile()
const alert = screen.getByRole('alert')
expect(alert.textContent).toContain('$5.00')
})
it('hides CTAs when canTopUp is false even when balance is owed', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = 'none'
state.canTopUp = false
renderTile()
const alert = screen.getByRole('alert')
expect(alert.textContent).toContain('Outstanding balance')
expect(screen.queryByText('Add a payment method')).toBeNull()
expect(screen.queryByTestId('terms-note')).toBeNull()
})
it('shows a neutral message when paymentMethodCapability is null', () => {
activeProSubscription()
state.balance = { amountMicros: 500, pendingChargesMicros: 3_000_000 }
state.paymentMethodCapability = null
state.canTopUp = true
renderTile()
const alert = screen.getByRole('alert')
expect(alert.textContent).toContain(
'Payment method status unavailable. Refresh to try again.'
)
expect(screen.queryByText('Pay now')).toBeNull()
expect(screen.queryByText('Add a payment method')).toBeNull()
})
})
})

View File

@@ -3,6 +3,7 @@
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
>
<Button
ref="refreshButtonRef"
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
@@ -143,6 +144,94 @@
</div>
</template>
<!-- Owed balance notice: shown only when there is a positive pending charge -->
<div
v-if="owedBalanceAmount !== null"
role="alert"
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
>
<i
class="mt-0.5 icon-[lucide--alert-triangle] size-4 shrink-0 text-base-foreground"
/>
<div class="flex flex-col gap-2">
<span class="text-base-foreground">{{
$t('billing.owedBalance.title', { amount: owedBalanceAmount })
}}</span>
<!-- one_time_only hint -->
<span
v-if="paymentMethodCapability === 'one_time_only'"
class="text-muted"
>{{ $t('billing.owedBalance.oneTimeOnlyHint') }}</span
>
<!-- reusable + flag off: read-only message -->
<span
v-if="
paymentMethodCapability === 'reusable' && !settleEndpointEnabled
"
class="text-muted"
>{{ $t('billing.owedBalance.chargeAutomatic') }}</span
>
<!-- consent note before add-payment-method CTA -->
<SubscriptionTermsNote
v-if="
permissions.canTopUp &&
(paymentMethodCapability === 'none' ||
paymentMethodCapability === 'one_time_only')
"
context="payment_method"
/>
<!-- CTA: none / one_time_only → add payment method -->
<Button
v-if="
permissions.canTopUp &&
(paymentMethodCapability === 'none' ||
paymentMethodCapability === 'one_time_only')
"
variant="primary"
size="sm"
class="w-fit"
@click="handleOwedAddPaymentMethod"
>
{{
paymentMethodCapability === 'none'
? $t('billing.owedBalance.addPaymentMethod')
: $t('billing.owedBalance.addCardOrBank')
}}
</Button>
<!-- CTA: reusable + flag on → Pay now -->
<Button
v-else-if="
permissions.canTopUp &&
paymentMethodCapability === 'reusable' &&
settleEndpointEnabled
"
ref="payNowButtonRef"
variant="primary"
size="sm"
class="w-fit"
:disabled="isPayingOwed || isPayingNow"
@click="handlePayNow"
>
<span v-if="isPayingOwed || isPayingNow" role="status">{{
$t('billing.owedBalance.processing')
}}</span>
<template v-else>{{ $t('billing.owedBalance.payNow') }}</template>
</Button>
<!-- capability loading/unknown: show a neutral message -->
<span
v-if="permissions.canTopUp && paymentMethodCapability === null"
class="text-muted"
>{{ $t('billing.owedBalance.unknownCapability') }}</span
>
</div>
</div>
<div v-if="showActionButton" class="flex flex-col gap-3">
<Button
v-if="isFreeTier"
@@ -176,13 +265,14 @@
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
@@ -193,7 +283,11 @@ import {
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
import { useTelemetry } from '@/platform/telemetry'
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import SubscriptionTermsNote from '@/platform/workspace/components/SubscriptionTermsNote.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useDialogService } from '@/services/dialogService'
const { zeroState = false } = defineProps<{
@@ -209,6 +303,7 @@ const {
isActiveSubscription,
isFreeTier,
currentTeamCreditStop,
paymentMethodCapability,
fetchBalance,
fetchStatus
} = useBillingContext()
@@ -225,6 +320,101 @@ const { showPricingTable } = useSubscriptionDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const { flags } = useFeatureFlags()
const billingOperationStore = useBillingOperationStore()
const toastStore = useToastStore()
const settleEndpointEnabled = computed(() => flags.settleEndpointEnabled)
const owedBalanceAmount = computed(() => {
const pendingMicros = balance.value?.pendingChargesMicros
if (pendingMicros == null || pendingMicros <= 0) return null
const rawCurrency = (balance.value?.currency ?? '').toUpperCase()
const currency = /^[A-Z]{3}$/.test(rawCurrency) ? rawCurrency : 'USD'
return (pendingMicros / 1_000_000).toLocaleString(locale.value, {
style: 'currency',
currency
})
})
const isPayingOwed = computed(() => billingOperationStore.isPayingOwed)
const isPayingNow = ref(false)
const payNowButtonRef = ref<InstanceType<typeof Button> | null>(null)
const refreshButtonRef = ref<InstanceType<typeof Button> | null>(null)
let payNowOpId: string | null = null
async function handleOwedAddPaymentMethod() {
try {
const response = await workspaceApi.initiateAddPaymentMethod()
const url = response.payment_method_url
if (!new URL(url).hostname.endsWith('.stripe.com')) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.unknownError')
})
return
}
const win = window.open(url, '_blank', 'noopener,noreferrer')
if (!win) {
toastStore.add({
severity: 'warn',
summary: t('g.warning'),
detail: t('subscription.preview.paymentPopupBlocked')
})
}
} catch (err) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError')
})
}
}
async function handlePayNow() {
if (isPayingOwed.value || isPayingNow.value) return
isPayingNow.value = true
const idempotencyKey = crypto.randomUUID()
if (payNowOpId) {
billingOperationStore.clearOperation(payNowOpId)
payNowOpId = null
}
try {
const response = await workspaceApi.settleOwedBalance(idempotencyKey)
payNowOpId = response.billing_op_id
const operation = await billingOperationStore.startOperation(
payNowOpId,
'pay_owed'
)
if (operation.status === 'succeeded') {
await nextTick()
const el = refreshButtonRef.value?.$el as HTMLElement | undefined
el?.focus()
} else {
await nextTick()
const el = payNowButtonRef.value?.$el as HTMLElement | undefined
el?.focus()
}
} catch (err) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError')
})
await nextTick()
const el = payNowButtonRef.value?.$el as HTMLElement | undefined
el?.focus()
} finally {
isPayingNow.value = false
}
}
const tierKey = computed(() => {
const tier = subscription.value?.tier
@@ -351,12 +541,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => {
})
function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
telemetry?.trackAddApiCreditButtonClicked()
void dialogService.showTopUpCreditsDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable({ reason: 'upgrade_to_add_credits' })
showPricingTable()
}
async function handleWindowFocus() {

View File

@@ -5,8 +5,6 @@ import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import FreeTierDialogContent from './FreeTierDialogContent.vue'
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
@@ -17,7 +15,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
}))
}))
function renderComponent(props?: { reason?: PaymentIntentSource }) {
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -25,7 +23,6 @@ function renderComponent(props?: { reason?: PaymentIntentSource }) {
})
return render(FreeTierDialogContent, {
props,
global: {
plugins: [i18n]
}
@@ -46,18 +43,4 @@ describe('FreeTierDialogContent', () => {
renderComponent()
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
it('keeps the generic copy for intent reasons outside the credits variants', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent({ reason: 'subscribe_to_run' })
expect(
screen.getByText('Your credits refresh on Jul 15, 2026.')
).toBeInTheDocument()
})
it('swaps to the out-of-credits copy without the refresh line', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent({ reason: 'out_of_credits' })
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
})

View File

@@ -52,7 +52,7 @@
</p>
<p
v-if="!isCreditsBlockedVariant"
v-if="!reason || reason === 'subscription_required'"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -65,7 +65,10 @@
</p>
<p
v-if="!isCreditsBlockedVariant && formattedRenewalDate"
v-if="
(!reason || reason === 'subscription_required') &&
formattedRenewalDate
"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -85,7 +88,7 @@
@click="$emit('upgrade')"
>
{{
isCreditsBlockedVariant
reason === 'out_of_credits' || reason === 'top_up_blocked'
? $t('subscription.freeTier.upgradeCta')
: $t('subscription.freeTier.subscribeCta')
}}
@@ -100,12 +103,12 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
const { reason } = defineProps<{
reason?: PaymentIntentSource
defineProps<{
reason?: SubscriptionDialogReason
}>()
defineEmits<{
@@ -126,10 +129,4 @@ const formattedRenewalDate = computed(() => {
})
const freeTierCredits = computed(() => getTierCredits('free'))
// Only these two variants replace the generic free-tier copy; any other
// intent reason (subscribe_to_run, deep_link, ...) keeps the default pitch.
const isCreditsBlockedVariant = computed(
() => reason === 'out_of_credits' || reason === 'top_up_blocked'
)
</script>

View File

@@ -261,7 +261,6 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
@@ -342,7 +341,6 @@ describe('PricingTable', () => {
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('should use the latest userId value when it changes after mount', async () => {
@@ -368,7 +366,6 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,6 +55,12 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
})
}))
const mockTrackSubscription = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
describe('usePricingTableUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -90,6 +96,9 @@ describe('usePricingTableUrlLoader', () => {
reason: 'deep_link',
planMode: undefined
})
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
reason: 'deep_link'
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})
@@ -141,6 +150,7 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('denies, strips, and clears together when the user is not eligible', async () => {
@@ -151,6 +161,7 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
@@ -219,6 +230,7 @@ describe('usePricingTableUrlLoader', () => {
)
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'

View File

@@ -7,6 +7,7 @@ import {
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -61,6 +62,7 @@ export function usePricingTableUrlLoader() {
const planMode =
param === 'team' || param === 'personal' ? param : undefined
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
}

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

View File

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

View File

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

View File

@@ -5,10 +5,8 @@ import { useSubscriptionDialog } from './useSubscriptionDialog'
const mockCloseDialog = vi.fn()
const mockShowLayoutDialog = vi.fn()
const mockShowTeamWorkspacesDialog = vi.fn()
const mockTrackSubscription = vi.hoisted(() => vi.fn())
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
@@ -62,15 +60,10 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isFreeTier: mockIsFreeTier,
isLegacyTeamPlan: mockIsLegacyTeamPlan,
tier: mockTier
isLegacyTeamPlan: mockIsLegacyTeamPlan
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
@@ -87,7 +80,6 @@ describe('useSubscriptionDialog', () => {
mockIsCloud.value = true
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTier.value = 'FREE'
mockTeamWorkspacesEnabled.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -206,51 +198,6 @@ describe('useSubscriptionDialog', () => {
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props.initialPlanMode).toBe('team')
})
it('tracks modal_opened with the caller reason and current tier', () => {
mockTier.value = 'STANDARD'
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'upgrade_to_add_credits' })
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
current_tier: 'standard',
reason: 'upgrade_to_add_credits'
})
})
it('tracks modal_opened on the workspace (unified) path too', () => {
mockTeamWorkspacesEnabled.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockTrackSubscription).toHaveBeenCalledWith(
'modal_opened',
expect.objectContaining({ reason: 'subscribe_to_run' })
)
})
it('does not track modal_opened for the inactive member dialog', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('does not track on non-cloud', () => {
mockIsCloud.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
})
describe('show', () => {
@@ -288,20 +235,6 @@ describe('useSubscriptionDialog', () => {
expect.objectContaining({ key: 'subscription-required' })
)
})
it('tracks modal_opened with the reason for the free-tier dialog', () => {
mockIsFreeTier.value = true
mockIsInPersonalWorkspace.value = true
const { show } = useSubscriptionDialog()
show({ reason: 'out_of_credits' })
expect(mockTrackSubscription).toHaveBeenCalledTimes(1)
expect(mockTrackSubscription).toHaveBeenCalledWith(
'modal_opened',
expect.objectContaining({ reason: 'out_of_credits' })
)
})
})
describe('startTeamWorkspaceUpgradeFlow', () => {

View File

@@ -4,8 +4,6 @@ import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -13,8 +11,14 @@ const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
export interface SubscriptionDialogOptions {
reason?: PaymentIntentSource
export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
/**
* Forces the unified pricing dialog to open on a specific plan tab,
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
@@ -34,17 +38,6 @@ export const useSubscriptionDialog = () => {
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
}
// Fired here — the choke point every paywall/pricing dialog variant passes
// through — so both the legacy and workspace billing paths emit it.
function trackModalOpened(reason?: PaymentIntentSource) {
// Resolved lazily to avoid the useBillingContext import cycle (see below).
const { tier } = useBillingContext()
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: tier.value?.toLowerCase(),
reason
})
}
function showPricingTable(options?: SubscriptionDialogOptions) {
if (!isCloud) return
@@ -78,8 +71,6 @@ export const useSubscriptionDialog = () => {
return
}
trackModalOpened(options?.reason)
// Shared dialog shell styling for both variants.
const dialogComponentProps = {
style: 'width: min(1328px, 95vw); max-height: 958px;',
@@ -176,8 +167,6 @@ export const useSubscriptionDialog = () => {
// (not at composable setup) to avoid the useBillingContext import cycle.
const { isFreeTier } = useBillingContext()
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
trackModalOpened(options?.reason)
const component = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
@@ -247,7 +236,7 @@ export const useSubscriptionDialog = () => {
sessionStorage.removeItem(RESUME_PRICING_KEY)
if (!workspaceStore.isInPersonalWorkspace) {
showPricingTable({ reason: 'team_upgrade_resume' })
showPricingTable()
}
} catch {
// sessionStorage may be unavailable

View File

@@ -1,49 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearPendingSubscriptionCheckoutAttempt,
consumePendingSubscriptionCheckoutSuccess,
recordPendingSubscriptionCheckoutAttempt
} from './subscriptionCheckoutTracker'
const activeProStatus = {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
} as const
describe('subscriptionCheckoutTracker', () => {
beforeEach(() => {
clearPendingSubscriptionCheckoutAttempt()
})
it('round-trips payment_intent_source from attempt to success metadata', () => {
recordPendingSubscriptionCheckoutAttempt({
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
expect(metadata).toMatchObject({
tier: 'pro',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
})
it('omits payment_intent_source when the attempt had none', () => {
recordPendingSubscriptionCheckoutAttempt({
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
})
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
expect(metadata).not.toBeNull()
expect(metadata).not.toHaveProperty('payment_intent_source')
})
})

View File

@@ -7,12 +7,7 @@ import type {
TierKey
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type {
BeginCheckoutMetadata,
PaymentIntentSource,
SubscriptionCheckoutType,
SubscriptionSuccessMetadata
} from '@/platform/telemetry/types'
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
const VALID_TIER_KEYS = new Set<TierKey>([
@@ -28,6 +23,7 @@ export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
'comfy:subscription-checkout-attempt-changed'
type CheckoutType = 'new' | 'change'
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
interface SubscriptionStatusSnapshot {
@@ -36,24 +32,22 @@ interface SubscriptionStatusSnapshot {
subscription_duration?: SubscriptionDuration | null
}
export interface PendingSubscriptionCheckoutAttempt {
interface PendingSubscriptionCheckoutAttempt {
attempt_id: string
started_at_ms: number
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
interface PendingSubscriptionCheckoutAttemptInput {
interface RecordPendingSubscriptionCheckoutAttemptInput {
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
const dispatchPendingCheckoutChangeEvent = () => {
@@ -174,9 +168,6 @@ const normalizeAttempt = (
...(candidate.previous_cycle === 'monthly' ||
candidate.previous_cycle === 'yearly'
? { previous_cycle: candidate.previous_cycle }
: {}),
...(typeof candidate.payment_intent_source === 'string'
? { payment_intent_source: candidate.payment_intent_source }
: {})
}
}
@@ -233,27 +224,20 @@ const getPendingSubscriptionCheckoutAttempt =
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
getPendingSubscriptionCheckoutAttempt() !== null
export const createPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
export const recordPendingSubscriptionCheckoutAttempt = (
input: RecordPendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt => {
return {
const storage = getStorage()
const attempt: PendingSubscriptionCheckoutAttempt = {
attempt_id: createAttemptId(),
started_at_ms: Date.now(),
tier: input.tier,
cycle: input.cycle,
checkout_type: input.checkout_type,
...(input.previous_tier ? { previous_tier: input.previous_tier } : {}),
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}),
...(input.payment_intent_source
? { payment_intent_source: input.payment_intent_source }
: {})
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
}
}
export const persistPendingSubscriptionCheckoutAttempt = (
attempt: PendingSubscriptionCheckoutAttempt
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
if (!storage) {
return attempt
}
@@ -271,21 +255,6 @@ export const persistPendingSubscriptionCheckoutAttempt = (
return attempt
}
export const recordPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt =>
persistPendingSubscriptionCheckoutAttempt(
createPendingSubscriptionCheckoutAttempt(input)
)
export const withPendingCheckoutAttemptId = (
metadata: BeginCheckoutMetadata,
attempt: PendingSubscriptionCheckoutAttempt
): BeginCheckoutMetadata => ({
...metadata,
checkout_attempt_id: attempt.attempt_id
})
const didAttemptSucceed = (
attempt: PendingSubscriptionCheckoutAttempt,
status: SubscriptionStatusSnapshot
@@ -318,9 +287,6 @@ export const consumePendingSubscriptionCheckoutSuccess = (
cycle: attempt.cycle,
checkout_type: attempt.checkout_type,
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
...(attempt.payment_intent_source
? { payment_intent_source: attempt.payment_intent_source }
: {}),
value,
currency: 'USD',
ecommerce: {

View File

@@ -132,14 +132,13 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'yearly')
await performSubscriptionCheckout('pro', 'yearly', true)
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-123',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String),
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
@@ -151,12 +150,6 @@ describe('performSubscriptionCheckout', () => {
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
})
const beginCheckoutMetadata =
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
JSON.parse(storedAttempt).attempt_id
)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/customers/cloud-subscription-checkout/pro-yearly'
@@ -193,7 +186,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly')
await performSubscriptionCheckout('pro', 'monthly', true)
expect(warnSpy).toHaveBeenCalledWith(
'[SubscriptionCheckout] Failed to collect checkout attribution',
@@ -210,43 +203,11 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-123',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
checkout_type: 'new'
})
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('carries the payment intent source into begin_checkout and the pending attempt', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => window as unknown as Window)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', {
paymentIntentSource: 'out_of_credits'
})
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({ payment_intent_source: 'out_of_credits' })
)
const beginCheckoutMetadata =
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
const pendingAttempt = JSON.parse(storedAttempt)
expect(pendingAttempt).toMatchObject({
payment_intent_source: 'out_of_credits'
})
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
pendingAttempt.attempt_id
)
openSpy.mockRestore()
})
it('uses the latest userId when it changes after checkout starts', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
@@ -261,7 +222,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly')
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
mockUserId.value = 'user-late'
authHeader.resolve({ Authorization: 'Bearer test-token' })
@@ -274,14 +235,13 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-late',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
checkout_type: 'new'
})
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('does not persist the pending attempt when the checkout popup is blocked', async () => {
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
@@ -290,18 +250,11 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly')
await performSubscriptionCheckout('pro', 'monthly', true)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
const storedAttempt = window.localStorage.getItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
)
expect(storedAttempt).toBeNull()
expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
checkout_attempt_id: expect.any(String)
})
)
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
})
})

View File

@@ -4,19 +4,12 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import {
createPendingSubscriptionCheckoutAttempt,
persistPendingSubscriptionCheckoutAttempt,
withPendingCheckoutAttemptId
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type {
CheckoutAttributionMetadata,
PaymentIntentSource
} from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import type { BillingCycle } from './subscriptionTierRank'
type CheckoutTier = TierKey | `${TierKey}-yearly`
@@ -38,11 +31,6 @@ const getCheckoutAttributionForCloud =
return getCheckoutAttribution()
}
interface PerformSubscriptionCheckoutOptions {
openInNewTab?: boolean
paymentIntentSource?: PaymentIntentSource
}
/**
* Core subscription checkout logic shared between PricingTable and
* SubscriptionRedirectView. Handles:
@@ -59,12 +47,10 @@ interface PerformSubscriptionCheckoutOptions {
export async function performSubscriptionCheckout(
tierKey: TierKey,
currentBillingCycle: BillingCycle,
options: PerformSubscriptionCheckoutOptions = {}
openInNewTab: boolean = true
): Promise<void> {
if (!isCloud) return
const { openInNewTab = true, paymentIntentSource } = options
const authStore = useAuthStore()
const { userId } = storeToRefs(authStore)
const telemetry = useTelemetry()
@@ -122,29 +108,14 @@ export async function performSubscriptionCheckout(
const data = await response.json()
if (data.checkout_url) {
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
payment_intent_source: paymentIntentSource
})
if (userId.value) {
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(
{
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...(paymentIntentSource
? { payment_intent_source: paymentIntentSource }
: {}),
...checkoutAttribution
},
pendingAttempt
)
)
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...checkoutAttribution
})
}
if (openInNewTab) {
@@ -152,9 +123,18 @@ export async function performSubscriptionCheckout(
if (!checkoutWindow) {
return
}
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
} else {
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
globalThis.location.href = data.checkout_url
}
}

View File

@@ -1,13 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, reactive } from 'vue'
const { mockIsCloud, mockSubscribe, mockTrackBeginCheckout, mockUserId } =
vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn(),
mockTrackBeginCheckout: vi.fn(),
mockUserId: { value: 'user-1' as string | null }
}))
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -20,12 +16,6 @@ vi.mock('@/config/comfyApi', () => ({
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { subscribe: mockSubscribe }
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackBeginCheckout: mockTrackBeginCheckout })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
}))
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
@@ -53,9 +43,7 @@ describe('performTeamSubscriptionCheckout', () => {
billing_op_id: 'op_1'
})
await performTeamSubscriptionCheckout('team_700', 'yearly', {
paymentIntentSource: 'deep_link'
})
await performTeamSubscriptionCheckout('team_700', 'yearly')
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
returnUrl: 'https://app.test/payment/success',
@@ -63,14 +51,6 @@ describe('performTeamSubscriptionCheckout', () => {
teamCreditStopId: 'team_700'
})
expect(assignedHref).toBe('https://stripe.test/pay')
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-1',
tier: 'team',
cycle: 'yearly',
checkout_type: 'new',
billing_op_id: 'op_1',
payment_intent_source: 'deep_link'
})
})
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
@@ -102,16 +82,6 @@ describe('performTeamSubscriptionCheckout', () => {
expect(assignedHref).toBeUndefined()
})
it('does not track begin_checkout when subscribe fails', async () => {
mockSubscribe.mockRejectedValueOnce(new Error('subscribe failed'))
await expect(
performTeamSubscriptionCheckout('team_700', 'yearly')
).rejects.toThrow('subscribe failed')
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('does nothing off cloud', async () => {
mockIsCloud.value = false

View File

@@ -1,16 +1,10 @@
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import { isCloud } from '@/platform/distribution/types'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
import type { BillingCycle } from './subscriptionTierRank'
interface PerformTeamSubscriptionCheckoutOptions {
paymentIntentSource?: PaymentIntentSource
}
/**
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
* link: subscribes to the per-credit Team plan at the chosen slider stop and
@@ -28,8 +22,7 @@ interface PerformTeamSubscriptionCheckoutOptions {
*/
export async function performTeamSubscriptionCheckout(
teamCreditStopId: string,
billingCycle: BillingCycle,
options: PerformTeamSubscriptionCheckoutOptions = {}
billingCycle: BillingCycle
): Promise<void> {
if (!isCloud) return
@@ -40,14 +33,6 @@ export async function performTeamSubscriptionCheckout(
teamCreditStopId
})
trackWorkspaceCheckoutStarted({
tier: 'team',
cycle: billingCycle,
checkoutType: 'new',
billingOpId: response.billing_op_id,
paymentIntentSource: options.paymentIntentSource
})
if (response.status === 'needs_payment_method') {
// A needs_payment_method response without a URL is unusable: surface it to
// the caller's error handling rather than silently dropping the user home

View File

@@ -30,39 +30,6 @@ describe('TelemetryRegistry', () => {
expect(b.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload)
})
it('dispatches trackBeginCheckout with intent metadata to every provider', () => {
const a: TelemetryProvider = { trackBeginCheckout: vi.fn() }
const b: TelemetryProvider = {}
const registry = new TelemetryRegistry()
registry.registerProvider(a)
registry.registerProvider(b)
const metadata = {
user_id: 'user-1',
tier: 'pro' as const,
cycle: 'monthly' as const,
checkout_type: 'new' as const,
payment_intent_source: 'subscribe_to_run' as const
}
registry.trackBeginCheckout(metadata)
expect(a.trackBeginCheckout).toHaveBeenCalledExactlyOnceWith(metadata)
})
it('dispatches trackAddApiCreditButtonClicked with its source', () => {
const provider: TelemetryProvider = {
trackAddApiCreditButtonClicked: vi.fn()
}
const registry = new TelemetryRegistry()
registry.registerProvider(provider)
registry.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
expect(
provider.trackAddApiCreditButtonClicked
).toHaveBeenCalledExactlyOnceWith({ source: 'credits_panel' })
})
it('skips providers that do not implement trackSearchQuery', () => {
const empty: TelemetryProvider = {}
const registry = new TelemetryRegistry()

View File

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

View File

@@ -313,42 +313,6 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('captures begin_checkout with intent metadata', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackBeginCheckout({
user_id: 'user-1',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.BEGIN_CHECKOUT,
{
user_id: 'user-1',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
}
)
})
it('captures add-credit clicks with their source', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
{ source: 'credits_panel' }
)
})
it('captures share attribution events', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()

View File

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

View File

@@ -115,17 +115,6 @@ describe('HostTelemetrySink', () => {
)
})
it('forwards add-credit clicks with their source', () => {
new HostTelemetrySink().trackAddApiCreditButtonClicked({
source: 'avatar_menu'
})
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
{ source: 'avatar_menu' }
)
})
it('does nothing when the host bridge is absent', () => {
delete window.__comfyDesktop2

View File

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

View File

@@ -12,29 +12,12 @@
* 3. Check dist/assets/*.js files contain no tracking code
*/
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { AuditLog } from '@/services/customerEventsService'
import type { AppMode } from '@/utils/appMode'
export type PaymentIntentSource =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
| 'subscribe_to_run'
| 'subscribe_now_button'
| 'upgrade_to_add_credits'
| 'settings_billing_panel'
| 'avatar_menu_plans'
| 'team_members_panel'
| 'invite_member_upsell'
| 'upload_model_upgrade'
| 'team_upgrade_resume'
export type SubscriptionCheckoutType = 'new' | 'change'
export type SubscriptionCheckoutTier = TierKey | 'team'
/**
* Authentication metadata for sign-up tracking
*/
@@ -443,23 +426,16 @@ export interface CheckoutAttributionMetadata {
export interface SubscriptionMetadata {
current_tier?: string
reason?: PaymentIntentSource
}
export interface AddCreditsClickMetadata {
source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel'
reason?: SubscriptionDialogReason
}
export interface BeginCheckoutMetadata
extends Record<string, unknown>, CheckoutAttributionMetadata {
user_id: string
tier: SubscriptionCheckoutTier
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_attempt_id?: string
billing_op_id?: string
checkout_type: 'new' | 'change'
previous_tier?: TierKey
payment_intent_source?: PaymentIntentSource
}
interface EcommerceItemMetadata {
@@ -481,9 +457,8 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
checkout_attempt_id: string
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_type: 'new' | 'change'
previous_tier?: TierKey
payment_intent_source?: PaymentIntentSource
value: number
currency: string
ecommerce: EcommerceMetadata
@@ -514,7 +489,7 @@ export interface TelemetryProvider {
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionCancelled?(): void
trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void

View File

@@ -258,6 +258,8 @@ export interface CurrentTeamCreditStop {
stop_usd: number
}
export type PaymentMethodCapability = 'none' | 'one_time_only' | 'reusable'
export interface BillingStatusResponse {
is_active: boolean
subscription_status?: BillingSubscriptionStatus
@@ -269,6 +271,8 @@ export interface BillingStatusResponse {
cancel_at?: string
renewal_date?: string
team_credit_stop?: CurrentTeamCreditStop
payment_method_capability?: PaymentMethodCapability
default_payment_method_type?: string
}
export interface BillingBalanceResponse {
@@ -285,13 +289,14 @@ interface CreateTopupRequest {
idempotency_key?: string
}
type TopupStatus = 'pending' | 'completed' | 'failed'
type TopupStatus = 'pending' | 'completed' | 'failed' | 'needs_payment_method'
export interface CreateTopupResponse {
billing_op_id: string
topup_id: string
status: TopupStatus
amount_cents: number
payment_method_url?: string
}
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
@@ -324,6 +329,15 @@ interface GetBillingEventsParams {
limit?: number
}
export interface AddPaymentMethodResponse {
payment_method_url: string
billing_op_id: string
}
export interface SettleOwedBalanceResponse {
billing_op_id: string
}
class WorkspaceApiError extends Error {
constructor(
message: string,
@@ -790,5 +804,43 @@ export const workspaceApi = {
} catch (err) {
handleAxiosError(err)
}
},
/**
* Initiate a Stripe SetupIntent to collect a payment method without charging.
* POST /api/billing/add-payment-method
*/
async initiateAddPaymentMethod(): Promise<AddPaymentMethodResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<AddPaymentMethodResponse>(
api.apiURL('/billing/add-payment-method'),
null,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Settle the outstanding owed balance immediately.
* POST /api/billing/settle-owed
*/
async settleOwedBalance(
idempotencyKey?: string
): Promise<SettleOwedBalanceResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<SettleOwedBalanceResponse>(
api.apiURL('/billing/settle-owed'),
idempotencyKey ? { idempotency_key: idempotencyKey } : null,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
}
}

View File

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

View File

@@ -0,0 +1,192 @@
<template>
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header -->
<div class="flex items-center justify-between p-8">
<h2 class="m-0 text-lg font-bold text-base-foreground">
<Skeleton v-if="ctaLoading" class="h-7 w-48" />
<template v-else>{{ title }}</template>
</h2>
<button
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
:aria-label="$t('g.close')"
@click="handleClose"
>
<i class="icon-[lucide--x] size-6" />
</button>
</div>
<!-- Capability error fallback -->
<p
v-if="capabilityError"
aria-live="polite"
data-testid="capability-error-fallback"
class="m-0 px-8 text-sm text-muted-foreground"
>
{{ $t('billing.spendLimit.capabilityError') }}
</p>
<!-- one_time_only info box -->
<div
v-else-if="capability === 'one_time_only'"
class="mx-8 flex items-start gap-2 rounded-lg bg-secondary-background p-3 text-sm"
>
<i
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
/>
<span class="text-base-foreground">{{
$t('billing.spendLimit.oneTimeOnlyInfo', { method: methodLabel })
}}</span>
</div>
<!-- Actions -->
<div class="flex flex-col gap-4 p-8">
<SubscriptionTermsNote
v-if="!(capability === 'reusable' && scenario === 'payment_failed')"
context="payment_method"
/>
<Button
:disabled="ctaLoading"
:loading="ctaLoading"
variant="primary"
size="lg"
class="h-10 justify-center"
:aria-label="ctaLabel"
@click="handleMainCta"
>
<Skeleton v-if="ctaLoading" class="h-4 w-32" />
<template v-else>{{ ctaLabel }}</template>
</Button>
<button
class="cursor-pointer border-none bg-transparent text-sm text-muted-foreground transition-colors hover:text-base-foreground"
@click="handleBuyManually"
>
{{ $t('billing.spendLimit.orBuyManually') }}
</button>
</div>
<!-- TODO: handle paused and dunning account states -->
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { PaymentMethodCapability } from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import SubscriptionTermsNote from '@/platform/workspace/components/SubscriptionTermsNote.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
type Scenario = 'limit_reached' | 'payment_failed'
const {
scenario,
capability,
methodType,
capabilityError = false
} = defineProps<{
scenario: Scenario
capability: PaymentMethodCapability
methodType?: string
capabilityError?: boolean
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const toastStore = useToastStore()
const { manageSubscription } = useBillingContext()
const ctaLoading = ref(false)
const methodLabel = computed(() => {
if (!methodType) return t('billing.spendLimit.defaultMethod')
const knownMethods = ['alipay', 'card', 'us_bank_account', 'link']
if (!knownMethods.includes(methodType))
return t('billing.spendLimit.defaultMethod')
return t(`billing.spendLimit.methodLabels.${methodType}`)
})
const title = computed(() => {
if (
capabilityError ||
capability === 'none' ||
capability === 'one_time_only'
) {
return t('billing.spendLimit.addPaymentMethodTitle')
}
if (scenario === 'payment_failed') {
return t('billing.spendLimit.paymentFailedTitle')
}
return t('billing.spendLimit.addPaymentMethodTitle')
})
const ctaLabel = computed(() => {
if (
capabilityError ||
capability === 'none' ||
capability === 'one_time_only'
) {
return t('billing.spendLimit.addPaymentMethodCta')
}
if (scenario === 'payment_failed') {
return t('billing.spendLimit.updatePaymentMethodCta')
}
return t('billing.spendLimit.addPaymentMethodCta')
})
function handleClose() {
dialogStore.closeDialog({ key: 'spend-limit' })
}
async function handleMainCta() {
if (ctaLoading.value) return
ctaLoading.value = true
try {
if (capability === 'reusable' && scenario === 'payment_failed') {
await manageSubscription()
} else {
const response = await workspaceApi.initiateAddPaymentMethod()
const url = response.payment_method_url
if (!new URL(url).hostname.endsWith('.stripe.com')) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.unknownError')
})
return
}
const paymentWindow = window.open(url, '_blank', 'noopener,noreferrer')
if (!paymentWindow) {
toastStore.add({
severity: 'warn',
summary: t('g.warning'),
detail: t('subscription.preview.paymentPopupBlocked')
})
}
}
} catch (err) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError')
})
} finally {
ctaLoading.value = false
}
}
async function handleBuyManually() {
handleClose()
await dialogService.showTopUpCreditsDialog()
}
</script>

View File

@@ -38,17 +38,22 @@ function previewFixture(
}
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}
})
const globalOptions = {
mocks: { $t: (key: string) => key },
stubs: {
'i18n-t': { template: '<span />' },
SubscriptionTermsNote: { template: '<div />' },
Button: {
template: '<button @click="$emit(\'click\')"><slot /></button>'
}

View File

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

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

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 { PaymentIntentSource } from '@/platform/telemetry/types'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue'
@@ -17,10 +17,25 @@ const mockHandleResubscribe = vi.fn()
const mockHandleSuccessClose = vi.fn()
const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing')
const mockPreviewData = ref<{ transition_type: string } | null>(null)
const mockUseSubscriptionCheckout = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
useSubscriptionCheckout: mockUseSubscriptionCheckout
useSubscriptionCheckout: () => ({
checkoutStep: mockCheckoutStep,
isLoadingPreview: ref(false),
loadingTier: ref(null),
isSubscribing: ref(false),
isResubscribing: ref(false),
previewData: mockPreviewData,
selectedTierKey: ref('standard'),
selectedBillingCycle: ref('yearly'),
isPolling: ref(false),
handleSubscribeClick: mockHandleSubscribeClick,
handleBackToPricing: mockHandleBackToPricing,
handleAddCreditCard: mockHandleAddCreditCard,
handleConfirmTransition: mockHandleConfirmTransition,
handleResubscribe: mockHandleResubscribe,
handleSuccessClose: mockHandleSuccessClose
})
}))
const i18n = createI18n({
@@ -76,7 +91,7 @@ const SuccessStub = {
function renderComponent(
props: {
onClose?: () => void
reason?: PaymentIntentSource
reason?: SubscriptionDialogReason
isPersonal?: boolean
} = {}
) {
@@ -106,23 +121,6 @@ function renderComponent(
describe('SubscriptionRequiredDialogContentWorkspace', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSubscriptionCheckout.mockReturnValue({
checkoutStep: mockCheckoutStep,
isLoadingPreview: ref(false),
loadingTier: ref(null),
isSubscribing: ref(false),
isResubscribing: ref(false),
previewData: mockPreviewData,
selectedTierKey: ref('standard'),
selectedBillingCycle: ref('yearly'),
isPolling: ref(false),
handleSubscribeClick: mockHandleSubscribeClick,
handleBackToPricing: mockHandleBackToPricing,
handleAddCreditCard: mockHandleAddCreditCard,
handleConfirmTransition: mockHandleConfirmTransition,
handleResubscribe: mockHandleResubscribe,
handleSuccessClose: mockHandleSuccessClose
})
mockCheckoutStep.value = 'pricing'
mockPreviewData.value = null
})
@@ -134,15 +132,6 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
})
it('passes the reason into subscription checkout', () => {
renderComponent({ reason: 'out_of_credits' })
expect(mockUseSubscriptionCheckout).toHaveBeenCalledWith(
expect.any(Function),
'out_of_credits'
)
})
it('shows the team workspace header by default', () => {
renderComponent()
expect(screen.getByText('Team Workspace')).toBeInTheDocument()

View File

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

View File

@@ -1,26 +1,54 @@
<template>
<p class="m-0 text-center text-xs text-muted-foreground">
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
<template #terms>
<a
href="https://www.comfy.org/terms"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.terms') }}
</a>
</template>
<template #privacy>
<a
href="https://www.comfy.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.privacyPolicy') }}
</a>
</template>
</i18n-t>
<template v-if="context === 'payment_method'">
<i18n-t keypath="billing.consent.paymentMethodBody" tag="span">
<template #settingsLink>
<button
class="cursor-pointer border-none bg-transparent p-0 text-xs text-muted-foreground underline transition-colors hover:text-base-foreground"
@click="openSettings"
>
{{ $t('billing.consent.settingsLink') }}
</button>
</template>
</i18n-t>
</template>
<template v-else>
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
<template #terms>
<a
href="https://www.comfy.org/terms"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.terms') }}
</a>
</template>
<template #privacy>
<a
href="https://www.comfy.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.privacyPolicy') }}
</a>
</template>
</i18n-t>
</template>
</p>
</template>
<script setup lang="ts">
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
const { context = 'subscription' } = defineProps<{
context?: 'subscription' | 'payment_method'
}>()
const settingsDialog = useSettingsDialog()
function openSettings() {
settingsDialog.show('workspace')
}
</script>

View File

@@ -9,12 +9,16 @@ import type {
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}
})
const globalOptions = {
mocks: { $t: (key: string) => key },

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, reactive } from 'vue'
import { computed } from 'vue'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { findPlanSlug } from './useSubscriptionCheckout'
@@ -76,9 +75,7 @@ const {
mockPlans,
mockResubscribe,
mockToastAdd,
mockStartOperation,
mockTrackBeginCheckout,
mockUserId
mockStartOperation
} = vi.hoisted(() => ({
mockSubscribe: vi.fn(),
mockPreviewSubscribe: vi.fn(),
@@ -87,9 +84,7 @@ const {
mockPlans: { value: [] as Plan[] },
mockResubscribe: vi.fn(),
mockToastAdd: vi.fn(),
mockStartOperation: vi.fn(),
mockTrackBeginCheckout: vi.fn(),
mockUserId: { value: 'user-1' as string | null }
mockStartOperation: vi.fn()
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
@@ -124,14 +119,7 @@ vi.mock('primevue/usetoast', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackMonthlySubscriptionSucceeded: vi.fn(),
trackBeginCheckout: mockTrackBeginCheckout
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() })
}))
vi.mock('vue-i18n', async (importOriginal) => {
@@ -147,10 +135,10 @@ vi.mock('vue-i18n', async (importOriginal) => {
describe('useSubscriptionCheckout', () => {
let emit: ReturnType<typeof vi.fn>
async function setup(paymentIntentSource?: PaymentIntentSource) {
async function setup() {
const { useSubscriptionCheckout } =
await import('./useSubscriptionCheckout')
return useSubscriptionCheckout(emit as never, paymentIntentSource)
return useSubscriptionCheckout(emit as never)
}
beforeEach(() => {
@@ -158,7 +146,6 @@ describe('useSubscriptionCheckout', () => {
vi.clearAllMocks()
mockPlans.value = allPlans()
mockStartOperation.mockResolvedValue({ status: 'succeeded' })
mockUserId.value = 'user-1'
emit = vi.fn()
})
@@ -472,13 +459,6 @@ describe('useSubscriptionCheckout', () => {
cancelUrl: 'https://platform.comfy.org/payment/failed'
})
expect(checkout.checkoutStep.value).toBe('success')
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
tier: 'team',
checkout_type: 'new',
billing_op_id: 'op-team-1'
})
)
})
it('uses the annual plan slug for the yearly cycle', async () => {
@@ -573,39 +553,6 @@ describe('useSubscriptionCheckout', () => {
detail: 'Team payment failed'
})
)
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('keeps team checkout_type as change when the preview request fails', async () => {
const checkout = await setup()
mockPreviewSubscribe.mockRejectedValueOnce(new Error('not supported'))
await checkout.handleSubscribeTeamClick({
stop: {
id: 'team_1400',
usd: 1400,
credits: 295_400,
discountedUsd: 1295
},
billingCycle: 'monthly',
isChange: true
})
mockSubscribe.mockResolvedValueOnce({
status: 'subscribed',
billing_op_id: 'op-team-change'
})
mockFetchStatus.mockResolvedValueOnce(undefined)
mockFetchBalance.mockResolvedValueOnce(undefined)
await checkout.handleTeamSubscribe()
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
tier: 'team',
cycle: 'monthly',
checkout_type: 'change',
billing_op_id: 'op-team-change'
})
)
})
})
@@ -656,47 +603,6 @@ describe('useSubscriptionCheckout', () => {
expect(checkout.checkoutStep.value).toBe('success')
})
it('skips begin_checkout when no user id is available', async () => {
mockUserId.value = null
const checkout = await setup('subscribe_to_run')
checkout.selectedTierKey.value = 'standard'
checkout.selectedBillingCycle.value = 'yearly'
mockSubscribe.mockResolvedValueOnce({
status: 'subscribed',
billing_op_id: 'op-1'
})
mockFetchStatus.mockResolvedValueOnce(undefined)
mockFetchBalance.mockResolvedValueOnce(undefined)
await checkout.handleAddCreditCard()
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
mockUserId.value = 'user-1'
})
it('fires begin_checkout carrying the payment intent source', async () => {
const checkout = await setup('subscribe_to_run')
checkout.selectedTierKey.value = 'standard'
checkout.selectedBillingCycle.value = 'yearly'
mockSubscribe.mockResolvedValueOnce({
status: 'subscribed',
billing_op_id: 'op-1'
})
mockFetchStatus.mockResolvedValueOnce(undefined)
mockFetchBalance.mockResolvedValueOnce(undefined)
await checkout.handleAddCreditCard()
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-1',
tier: 'standard',
cycle: 'yearly',
checkout_type: 'new',
billing_op_id: 'op-1',
payment_intent_source: 'subscribe_to_run'
})
})
it('opens payment URL when needs_payment_method', async () => {
const checkout = await setup()
checkout.selectedTierKey.value = 'standard'
@@ -814,7 +720,6 @@ describe('useSubscriptionCheckout', () => {
detail: 'Payment failed'
})
)
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
})

View File

@@ -9,26 +9,16 @@ import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { useTelemetry } from '@/platform/telemetry'
import type {
PaymentIntentSource,
SubscriptionCheckoutType
} from '@/platform/telemetry/types'
import type {
Plan,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
type CheckoutStep = 'pricing' | 'preview' | 'success'
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
interface SelectedTeamCheckout {
stop: TeamPlanSelection
checkoutType: SubscriptionCheckoutType
}
/**
* Which screen the `preview` step shows. Only a change prorates: a team change
* carries `previewData` (handleSubscribeTeamClick sets it solely for an immediate
@@ -55,12 +45,9 @@ export function findPlanSlug(
return plan?.slug ?? null
}
export function useSubscriptionCheckout(
emit: {
(e: 'close', subscribed: boolean): void
},
paymentIntentSource?: PaymentIntentSource
) {
export function useSubscriptionCheckout(emit: {
(e: 'close', subscribed: boolean): void
}) {
const { t } = useI18n()
const toast = useToast()
const {
@@ -81,16 +68,13 @@ export function useSubscriptionCheckout(
const isResubscribing = ref(false)
const previewData = ref<PreviewSubscribeResponse | null>(null)
const selectedTierKey = ref<CheckoutTierKey | null>(null)
const selectedTeamCheckout = ref<SelectedTeamCheckout | null>(null)
const selectedTeamStop = ref<TeamPlanSelection | null>(null)
const selectedBillingCycle = ref<BillingCycle>('yearly')
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
const selectedTeamStop = computed(
() => selectedTeamCheckout.value?.stop ?? null
)
const isTeamCheckout = computed(() => selectedTeamCheckout.value !== null)
const isTeamCheckout = computed(() => selectedTeamStop.value !== null)
const previewVariant = computed<PreviewVariant>(() => {
if (selectedTeamCheckout.value) {
if (selectedTeamStop.value) {
return previewData.value ? 'team-change' : 'team-new'
}
if (previewData.value) {
@@ -170,10 +154,7 @@ export function useSubscriptionCheckout(
billingCycle: BillingCycle
isChange?: boolean
}) {
selectedTeamCheckout.value = {
stop: payload.stop,
checkoutType: payload.isChange ? 'change' : 'new'
}
selectedTeamStop.value = payload.stop
selectedBillingCycle.value = payload.billingCycle
selectedTierKey.value = null
previewData.value = null
@@ -201,7 +182,7 @@ export function useSubscriptionCheckout(
function handleBackToPricing() {
checkoutStep.value = 'pricing'
previewData.value = null
selectedTeamCheckout.value = null
selectedTeamStop.value = null
}
function handleSuccessClose() {
@@ -209,34 +190,20 @@ export function useSubscriptionCheckout(
}
async function handleSubscription() {
const tierKey = selectedTierKey.value
if (!tierKey) return
const billingCycle = selectedBillingCycle.value
const checkoutType =
previewData.value &&
previewData.value.transition_type !== 'new_subscription'
? 'change'
: 'new'
if (!selectedTierKey.value) return
isSubscribing.value = true
try {
const planSlug = getApiPlanSlug(tierKey, billingCycle)
const planSlug = getApiPlanSlug(
selectedTierKey.value,
selectedBillingCycle.value
)
if (!planSlug) return
const response = await subscribe(planSlug, {
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
})
if (response) {
trackWorkspaceCheckoutStarted({
tier: tierKey,
cycle: billingCycle,
checkoutType,
billingOpId: response.billing_op_id,
paymentIntentSource
})
}
await handleSubscribeResponse(response)
} catch (error) {
showSubscribeError(error)
@@ -302,8 +269,8 @@ export function useSubscriptionCheckout(
}
async function handleTeamSubscription() {
const teamCheckout = selectedTeamCheckout.value
if (!teamCheckout?.stop.id) {
const stop = selectedTeamStop.value
if (!stop?.id) {
toast.add({
severity: 'error',
summary: t('subscription.teamPlan.name'),
@@ -312,28 +279,16 @@ export function useSubscriptionCheckout(
return
}
const { stop, checkoutType } = teamCheckout
const billingCycle = selectedBillingCycle.value
isSubscribing.value = true
try {
const planSlug = getTeamPlanSlug(billingCycle)
const planSlug = getTeamPlanSlug(selectedBillingCycle.value)
const response = await subscribe(planSlug, {
teamCreditStopId: stop.id,
billingCycle,
billingCycle: selectedBillingCycle.value,
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
})
if (response) {
trackWorkspaceCheckoutStarted({
tier: 'team',
cycle: billingCycle,
checkoutType,
billingOpId: response.billing_op_id,
paymentIntentSource
})
}
await handleSubscribeResponse(response)
} catch (error) {
showSubscribeError(error)

View File

@@ -2,7 +2,6 @@ import { computed, ref, shallowRef } from 'vue'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingBalanceResponse,
BillingStatusResponse,
@@ -71,7 +70,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
effectiveBalanceMicros:
data.effective_balance_micros ?? data.amount_micros,
prepaidBalanceMicros: data.prepaid_balance_micros ?? 0,
cloudCreditBalanceMicros: data.cloud_credit_balance_micros ?? 0
cloudCreditBalanceMicros: data.cloud_credit_balance_micros ?? 0,
pendingChargesMicros: data.pending_charges_micros
}
})
@@ -81,6 +81,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
)
const tier = computed(() => statusData.value?.subscription_tier ?? null)
const renewalDate = computed(() => statusData.value?.renewal_date ?? null)
const paymentMethodCapability = computed(
() => statusData.value?.payment_method_capability ?? null
)
const defaultPaymentMethodType = computed(
() => statusData.value?.default_payment_method_type ?? null
)
const plans = computed(() => billingPlans.plans.value)
const currentPlanSlug = computed(
@@ -276,12 +282,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
subscriptionDialog.show({ reason: 'subscription_required' })
subscriptionDialog.show()
}
}
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
subscriptionDialog.show(options)
function showSubscriptionDialog(): void {
subscriptionDialog.show()
}
return {
@@ -301,6 +307,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
subscriptionStatus,
tier,
renewalDate,
paymentMethodCapability,
defaultPaymentMethodType,
// Actions
initialize,

View File

@@ -504,6 +504,161 @@ describe('billingOperationStore', () => {
})
})
describe('pay_owed operations', () => {
it('shows immediate processing toast for pay_owed operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'pay_owed')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'info',
summary: 'billingOperation.payOwedProcessing',
group: 'billing-operation'
})
})
it('does not close any dialog or open settings on pay_owed success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
await terminal
expect(mockCloseDialog).not.toHaveBeenCalled()
expect(mockSettingsDialogShow).not.toHaveBeenCalled()
})
it('shows pay_owed success toast on success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
await terminal
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'billingOperation.payOwedSuccess',
life: 5000
})
})
it('uses pay_owed failure message on failure', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.payOwedFailed',
detail: undefined
})
})
it('uses pay_owed timeout message on timeout', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(121_000)
await vi.runAllTimersAsync()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.payOwedTimeout'
})
})
it('isPayingOwed is true while pay_owed operation is pending', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'pay_owed')
expect(store.isPayingOwed).toBe(true)
})
it('isPayingOwed is false after pay_owed operation succeeds', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
void store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
expect(store.isPayingOwed).toBe(false)
})
it('does not update workspace isSubscribed on pay_owed success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
await terminal
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
})
it('refreshes billing status and balance on pay_owed success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
const terminal = store.startOperation('op-1', 'pay_owed')
await vi.advanceTimersByTimeAsync(0)
await terminal
expect(mockFetchStatus).toHaveBeenCalled()
expect(mockFetchBalance).toHaveBeenCalled()
})
})
describe('exponential backoff', () => {
it('uses exponential backoff for polling intervals', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({

View File

@@ -16,7 +16,7 @@ const MAX_INTERVAL_MS = 8000
const BACKOFF_MULTIPLIER = 1.5
const TIMEOUT_MS = 120_000 // 2 minutes
type OperationType = 'subscription' | 'topup' | 'cancel'
type OperationType = 'subscription' | 'topup' | 'cancel' | 'pay_owed'
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
interface BillingOperation {
@@ -53,6 +53,12 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
)
)
const isPayingOwed = computed(() =>
[...operations.value.values()].some(
(op) => op.status === 'pending' && op.type === 'pay_owed'
)
)
function getOperation(opId: string) {
return operations.value.get(opId)
}
@@ -81,7 +87,9 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
const messageKey =
type === 'subscription'
? 'billingOperation.subscriptionProcessing'
: 'billingOperation.topupProcessing'
: type === 'topup'
? 'billingOperation.topupProcessing'
: 'billingOperation.payOwedProcessing'
const toastMessage: ToastMessageOptions = {
severity: 'info',
@@ -169,6 +177,17 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
return
}
// pay_owed: only refresh balance; do not close any dialog or open settings.
if (operation.type === 'pay_owed') {
useToastStore().add({
severity: 'success',
summary: t('billingOperation.payOwedSuccess'),
life: 5000
})
resolveTerminal(opId)
return
}
// A subscription checkout shows its own success step in the pricing dialog,
// so leave it open. Top-ups have no such step: close and surface settings.
if (operation.type === 'topup') {
@@ -233,6 +252,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
function failureMessage(type: OperationType) {
if (type === 'subscription') return t('billingOperation.subscriptionFailed')
if (type === 'topup') return t('billingOperation.topupFailed')
if (type === 'pay_owed') return t('billingOperation.payOwedFailed')
return t('billingOperation.cancelFailed')
}
@@ -240,6 +260,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
if (type === 'subscription')
return t('billingOperation.subscriptionTimeout')
if (type === 'topup') return t('billingOperation.topupTimeout')
if (type === 'pay_owed') return t('billingOperation.payOwedTimeout')
return t('billingOperation.cancelTimeout')
}
@@ -295,6 +316,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
hasPendingOperations,
isSettingUp,
isAddingCredits,
isPayingOwed,
getOperation,
startOperation,
clearOperation

View File

@@ -1,38 +0,0 @@
import { useTelemetry } from '@/platform/telemetry'
import type {
PaymentIntentSource,
SubscriptionCheckoutTier,
SubscriptionCheckoutType
} from '@/platform/telemetry/types'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { useAuthStore } from '@/stores/authStore'
interface TrackWorkspaceCheckoutStartedOptions {
tier: SubscriptionCheckoutTier
cycle: BillingCycle
checkoutType: SubscriptionCheckoutType
billingOpId: string
paymentIntentSource?: PaymentIntentSource
}
export function trackWorkspaceCheckoutStarted({
tier,
cycle,
checkoutType,
billingOpId,
paymentIntentSource
}: TrackWorkspaceCheckoutStartedOptions) {
const { userId } = useAuthStore()
if (!userId) return
useTelemetry()?.trackBeginCheckout({
user_id: userId,
tier,
cycle,
checkout_type: checkoutType,
billing_op_id: billingOpId,
...(paymentIntentSource
? { payment_intent_source: paymentIntentSource }
: {})
})
}

View File

@@ -1,208 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeError } from '@/schemas/apiSchema'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
isActiveSubscription: true
}))
const overlayMock = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing'
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billingMock.isActiveSubscription
})
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: overlayMock.overlayMessage,
overlayTitle: overlayMock.overlayTitle
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
},
mobileNoWorkflow: 'No workflow',
runCount: 'Run count',
viewJob: 'View job'
},
menu: {
run: 'Run'
},
menuLabels: {
publish: 'Publish'
},
queue: {
jobAddedToQueue: 'Job added to queue',
jobQueueing: 'Queueing'
}
}
}
})
const nodeErrors: Record<string, NodeError> = {
'1': {
class_type: 'TestNode',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Missing input',
details: '',
extra_info: { input_name: 'prompt' }
}
]
}
}
function renderControls({
hasError = false,
isActiveSubscription = true,
mobile = false
}: {
hasError?: boolean
isActiveSubscription?: boolean
mobile?: boolean
} = {}) {
billingMock.isActiveSubscription = isActiveSubscription
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
setActivePinia(pinia)
useAppModeStore().selectedOutputs = [toNodeId(1)]
if (hasError) {
useExecutionErrorStore().lastNodeErrors = nodeErrors
}
const toastTarget = document.createElement('div')
return render(LinearControls, {
props: { mobile, toastTo: toastTarget },
global: {
plugins: [pinia, i18n],
stubs: {
AppModeWidgetList: true,
Loader: true,
PartnerNodesList: true,
Popover: {
template: '<div><slot name="button" /><slot /></div>'
},
ScrubableNumberInput: true,
SubscribeToRunButton: true
}
}
})
}
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
billingMock.isActiveSubscription = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])('shows a workflow error warning in $label controls', ({ mobile }) => {
renderControls({ hasError: true, mobile })
const warning = screen.getByRole('status')
expect(
within(warning).getByText('Required input missing')
).toBeInTheDocument()
expect(
within(warning).getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(
within(warning).getByRole('button', { name: 'Show errors in graph' })
).toBeInTheDocument()
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
const runButton = screen.getByRole('button', { name: 'Run' })
expect(runButton).toHaveAttribute(
'aria-describedby',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without graph errors',
({ mobile }) => {
renderControls({ mobile })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show errors in graph' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
}
)
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without an active subscription',
({ mobile }) => {
renderControls({
hasError: true,
isActiveSubscription: false,
mobile
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
}
)
it('does not show the warning when the error copy is empty', () => {
overlayMock.overlayMessage = ''
renderControls({ hasError: true })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
})
})

View File

@@ -1,11 +1,10 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, toValue, useTemplateRef } from 'vue'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
@@ -15,15 +14,11 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
@@ -33,8 +28,6 @@ const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
const { overlayMessage } = useErrorOverlayState()
const { toastTo, mobile } = defineProps<{
toastTo?: string | HTMLElement
@@ -50,13 +43,6 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
{ controls: true, immediate: false }
)
const widgetListRef = useTemplateRef('widgetListRef')
const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
toValue(isActiveSubscription) &&
toValue(overlayMessage).trim().length > 0
)
//TODO: refactor out of this file.
//code length is small, but changes should propagate
@@ -148,10 +134,9 @@ function handleDragDrop() {
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
:data-testid="linearRunButtonTestId"
data-testid="linear-run-button"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
@@ -181,24 +166,18 @@ function handleDragDrop() {
variant="primary"
class="grow"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</section>
<section
v-else
:data-testid="linearRunButtonTestId"
data-testid="linear-run-button"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
@@ -219,14 +198,9 @@ function handleDragDrop() {
variant="primary"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</section>

View File

@@ -1,92 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const mocks = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing',
viewErrorsInGraph: vi.fn()
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: mocks.overlayMessage,
overlayTitle: mocks.overlayTitle
})
}))
vi.mock('@/composables/useViewErrorsInGraph', () => ({
useViewErrorsInGraph: () => ({
viewErrorsInGraph: mocks.viewErrorsInGraph
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
}
}
}
}
})
function renderWarning() {
const user = userEvent.setup()
const result = render(LinearRunErrorWarning, {
global: { plugins: [i18n] }
})
return { ...result, user }
}
describe('LinearRunErrorWarning', () => {
beforeEach(() => {
mocks.viewErrorsInGraph.mockReset()
})
it('shows the current error overlay title and message without a close action', () => {
renderWarning()
const warning = screen.getByRole('status')
expect(warning).toHaveTextContent('Required input missing')
expect(warning).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(screen.getByText('Required input missing')).toHaveAttribute(
'title',
'Required input missing'
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
})
it('opens graph errors when the action is clicked', async () => {
const { user } = renderWarning()
await user.click(
screen.getByRole('button', { name: 'Show errors in graph' })
)
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
})
})

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const { t } = useI18n()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { overlayMessage, overlayTitle } = useErrorOverlayState()
</script>
<template>
<div
role="status"
data-testid="linear-validation-warning"
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
>
<div
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
data-testid="linear-validation-warning-description"
class="flex flex-col gap-2"
>
<div class="flex w-full items-start gap-2">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
/>
<span
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
:title="overlayTitle"
>
{{ overlayTitle }}
</span>
</div>
<div
class="flex w-full items-start gap-2"
data-testid="linear-validation-warning-message"
>
<span class="size-4 shrink-0" aria-hidden="true" />
<p
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ overlayMessage }}
</p>
</div>
</div>
<div class="flex w-full items-center justify-end pt-2">
<Button
variant="secondary"
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="linear-view-errors"
@click="viewErrorsInGraph"
>
{{ t('linearMode.error.goto') }}
</Button>
</div>
</div>
</template>

View File

@@ -1,2 +0,0 @@
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
'linear-run-error-warning'

View File

@@ -18,8 +18,11 @@ import type {
} from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PaymentMethodCapability,
WorkspaceRole
} from '@/platform/workspace/api/workspaceApi'
// Lazy loaders for dialogs - components are loaded on first use
const lazyApiNodesSignInContent = () =>
@@ -442,9 +445,9 @@ export const useDialogService = () => {
})
}
async function showSubscriptionRequiredDialog(
options?: SubscriptionDialogOptions
) {
async function showSubscriptionRequiredDialog(options?: {
reason?: SubscriptionDialogReason
}) {
if (!isCloud || !window.__CONFIG__?.subscription_required) {
return
}
@@ -629,6 +632,25 @@ export const useDialogService = () => {
* understand" confirm dialog when the workspace has no other members;
* failures on that path surface as an error toast.
*/
async function showSpendLimitDialog(options: {
scenario: 'limit_reached' | 'payment_failed'
capability: PaymentMethodCapability
methodType?: string
capabilityError?: boolean
}) {
const { type } = useBillingContext()
if (type.value !== 'workspace') return
const { default: component } =
await import('@/platform/workspace/components/SpendLimitDialogContent.vue')
return dialogStore.showDialog({
key: 'spend-limit',
component,
props: options,
dialogComponentProps: workspaceDialogProps
})
}
async function showDowngradeToPersonalDialog(options: {
planName: string
planSlug: string
@@ -734,6 +756,7 @@ export const useDialogService = () => {
showInviteMemberUpsellDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog,
showDowngradeToPersonalDialog
showDowngradeToPersonalDialog,
showSpendLimitDialog
}
}

View File

@@ -33,6 +33,8 @@ export function useBillingContext(): BillingContext {
subscriptionStatus: computed(() => null),
tier: computed(() => null),
renewalDate: computed(() => null),
paymentMethodCapability: computed(() => null),
defaultPaymentMethodType: computed(() => null),
getMaxSeats: (tierKey: string) => ({ creator: 5, pro: 20 })[tierKey] ?? 1,
initialize: async () => {},
fetchStatus: async () => {},

View File

@@ -9,6 +9,7 @@ import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
@@ -164,6 +165,7 @@ function dragDrop(e: DragEvent) {
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
@@ -23,8 +22,6 @@ type NodePack = components['schemas']['Node']
describe('usePacksSelection', () => {
let managerStore: ReturnType<typeof useComfyManagerStore>
let mockIsPackInstalled: (packName: string | undefined) => boolean
let mockGetInstalledPackVersion: (packName: string) => string | undefined
let mockIsPackEnabled: (packName: string | undefined) => boolean
const createMockPack = (id: string): NodePack => ({
id,
@@ -47,12 +44,7 @@ describe('usePacksSelection', () => {
// Mock the isPackInstalled method
mockIsPackInstalled = vi.fn()
mockGetInstalledPackVersion = vi.fn()
mockIsPackEnabled = vi.fn()
managerStore.isPackInstalled = mockIsPackInstalled
// Real store types this as returning string, but it can be undefined at runtime
managerStore.getInstalledPackVersion = fromAny(mockGetInstalledPackVersion)
managerStore.isPackEnabled = mockIsPackEnabled
})
afterEach(() => {
@@ -383,35 +375,5 @@ describe('usePacksSelection', () => {
expect(installedPacks.value).toHaveLength(2)
expect(notInstalledPacks.value).toHaveLength(0)
})
it('should only include enabled installed packs with non-semver versions as nightly', () => {
const nodePacks = ref<NodePack[]>([
{ ...createMockPack('missing-id'), id: undefined },
createMockPack('no-version'),
createMockPack('stable'),
createMockPack('disabled-nightly'),
createMockPack('enabled-nightly')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
vi.mocked(mockGetInstalledPackVersion).mockImplementation((id) => {
const versions: Record<string, string | undefined> = {
stable: '1.2.3',
'disabled-nightly': 'abc123',
'enabled-nightly': 'def456'
}
return versions[id]
})
vi.mocked(mockIsPackEnabled).mockImplementation(
(id) => id === 'enabled-nightly'
)
const { nightlyPacks, hasNightlyPacks } = usePacksSelection(nodePacks)
expect(nightlyPacks.value.map((pack) => pack.id)).toEqual([
'enabled-nightly'
])
expect(hasNightlyPacks.value).toBe(true)
})
})
})

View File

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

View File

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

View File

@@ -10,15 +10,10 @@ import { useInstalledPacks } from '@/workbench/extensions/manager/composables/no
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import type { ConflictAcknowledgmentState } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import {
checkAcceleratorCompatibility,
checkOSCompatibility
} from '@/workbench/extensions/manager/utils/systemCompatibility'
import { checkVersionCompatibility } from '@/workbench/extensions/manager/utils/versionUtil'
// Mock @vueuse/core until function
@@ -223,14 +218,12 @@ describe('useConflictDetection', () => {
typeof useSystemStatsStore
>
const mockShouldShowConflictModal = ref(false)
const mockAcknowledgment = {
checkComfyUIVersionChange: vi.fn(),
acknowledgmentState: computed(
() => ({}) as Partial<ConflictAcknowledgmentState>
),
shouldShowConflictModal: computed(() => mockShouldShowConflictModal.value),
shouldShowConflictModal: computed(() => false),
shouldShowRedDot: computed(() => false),
shouldShowManagerBanner: computed(() => false),
dismissRedDotNotification: vi.fn(),
@@ -253,12 +246,6 @@ describe('useConflictDetection', () => {
vi.mocked(useInstalledPacks).mockReturnValue(mockInstalledPacks)
vi.mocked(useComfyManagerStore).mockReturnValue(mockManagerStore)
vi.mocked(useConflictDetectionStore).mockReturnValue(mockConflictStore)
vi.mocked(useManagerState).mockReturnValue({
isNewManagerUI: ref(true)
} as ReturnType<typeof useManagerState>)
vi.mocked(checkVersionCompatibility).mockReturnValue(null)
vi.mocked(checkOSCompatibility).mockReturnValue(null)
vi.mocked(checkAcceleratorCompatibility).mockReturnValue(null)
// Reset mock implementations
vi.mocked(mockInstalledPacks.startFetchInstalled).mockResolvedValue(
@@ -276,7 +263,6 @@ describe('useConflictDetection', () => {
mockInstalledPacksWithVersions.value = []
// Reset conflicted packages
mockConflictedPackages = []
mockShouldShowConflictModal.value = false
})
afterEach(() => {
@@ -310,21 +296,6 @@ describe('useConflictDetection', () => {
accelerator: ''
})
})
it('falls back when collecting system environment throws', async () => {
vi.mocked(useSystemStatsStore).mockImplementation(() => {
throw new Error('stats unavailable')
})
const { collectSystemEnvironment } = useConflictDetection()
await expect(collectSystemEnvironment()).resolves.toEqual({
comfyui_version: undefined,
frontend_version: undefined,
os: undefined,
accelerator: undefined
})
})
})
describe('conflict detection', () => {
@@ -475,240 +446,6 @@ describe('useConflictDetection', () => {
required_value: 'Import error'
})
})
it('uses fallbacks for pending packages and missing registry metadata', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{ id: 'pending-pack' } as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'missing-pack', version: '1.0.0' },
{ id: 'pending-pack', version: '2.0.0' }
]
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: [
{
status: 'success' as const,
identifier: { node_id: 'pending-pack', version: '2.0.0' },
node_version: {
status: 'NodeVersionStatusPending' as const,
version: '2.0.0',
publisher_id: 'publisher',
node_id: 'pending-pack',
created_at: '2024-01-01T00:00:00Z',
supported_comfyui_version: undefined,
supported_comfyui_frontend_version: undefined,
supported_os: undefined,
supported_accelerators: undefined
} as components['schemas']['NodeVersion']
}
]
})
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results).toHaveLength(1)
expect(result.results[0]).toMatchObject({
package_id: 'pending-pack',
has_conflict: true
})
expect(result.results[0].conflicts).toContainEqual({
type: 'pending',
current_value: 'installed',
required_value: 'not_pending'
})
})
it('records compatibility conflicts from version, OS, and accelerator checks', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{
id: 'compat-pack',
name: 'Compatibility Pack'
} as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'compat-pack', version: '1.0.0' }
]
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: [
{
status: 'success' as const,
identifier: { node_id: 'compat-pack', version: '1.0.0' },
node_version: {
status: 'NodeVersionStatusActive' as const,
version: '1.0.0',
publisher_id: 'publisher',
node_id: 'compat-pack',
created_at: '2024-01-01T00:00:00Z',
supported_comfyui_version: '>=9.0.0',
supported_comfyui_frontend_version: '>=9.0.0',
supported_os: ['Windows'],
supported_accelerators: ['CUDA']
} as components['schemas']['NodeVersion']
}
]
})
vi.mocked(checkVersionCompatibility).mockImplementation((type) => ({
type,
current_value: type,
required_value: '>=9.0.0'
}))
vi.mocked(checkOSCompatibility).mockReturnValue({
type: 'os',
current_value: 'macOS',
required_value: 'Windows'
})
vi.mocked(checkAcceleratorCompatibility).mockReturnValue({
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
})
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results[0].conflicts).toEqual(
expect.arrayContaining([
{
type: 'comfyui_version',
current_value: 'comfyui_version',
required_value: '>=9.0.0'
},
{
type: 'frontend_version',
current_value: 'frontend_version',
required_value: '>=9.0.0'
},
{ type: 'os', current_value: 'macOS', required_value: 'Windows' },
{
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
}
])
)
})
it('returns no results when installed packs are not ready', async () => {
mockInstalledPacks.isReady.value = false
mockInstalledPacks.installedPacks.value = [
{ id: 'not-ready' } as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'not-ready', version: '1.0.0' }
]
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results).toEqual([])
expect(mockConflictStore.clearConflicts).toHaveBeenCalled()
})
it('continues when registry bulk lookup fails', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{ id: 'fallback-pack' } as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'fallback-pack', version: '1.0.0' }
]
vi.mocked(mockRegistryService.getBulkNodeVersions).mockRejectedValue(
new Error('registry down')
)
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.success).toBe(true)
expect(result.results).toEqual([
{
package_id: 'fallback-pack',
package_name: 'fallback-pack',
has_conflict: false,
conflicts: [],
is_compatible: true
}
])
})
it('continues when import failure lookup throws', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{ id: 'clean-pack' } as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'clean-pack', version: '1.0.0' }
]
vi.mocked(
mockComfyManagerService.getImportFailInfoBulk
).mockRejectedValue(new Error('manager down'))
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.success).toBe(true)
expect(result.results).toEqual([
{
package_id: 'clean-pack',
package_name: 'clean-pack',
has_conflict: false,
conflicts: [],
is_compatible: true
}
])
})
it('uses unknown import error text when failure details omit messages', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacksWithVersions.value = [
{ id: 'unknown-fail-pack', version: '1.0.0' }
]
vi.mocked(
mockComfyManagerService.getImportFailInfoBulk
).mockResolvedValue({
'unknown-fail-pack': {},
'clean-pack': null
})
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results[0].conflicts).toContainEqual({
type: 'import_failed',
current_value: 'Unknown import error',
required_value: 'Unknown import error'
})
})
it('returns an in-progress response when analysis is already running', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{ id: 'slow-pack' } as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{ id: 'slow-pack', version: '1.0.0' }
]
let resolveFetch: () => void = () => {}
vi.mocked(mockInstalledPacks.startFetchInstalled).mockReturnValue(
new Promise<void>((resolve) => {
resolveFetch = resolve
})
)
const { runFullConflictAnalysis } = useConflictDetection()
const firstRun = runFullConflictAnalysis()
const secondRun = await runFullConflictAnalysis()
resolveFetch()
await firstRun
expect(secondRun).toMatchObject({
success: false,
error_message: 'Already detecting conflicts'
})
})
})
describe('computed properties', () => {
@@ -754,151 +491,5 @@ describe('useConflictDetection', () => {
])
).resolves.not.toThrow()
})
it('skips initialization when the new manager UI is disabled', async () => {
vi.mocked(useManagerState).mockReturnValue({
isNewManagerUI: ref(false)
} as ReturnType<typeof useManagerState>)
const { initializeConflictDetection } = useConflictDetection()
await initializeConflictDetection()
expect(mockInstalledPacks.startFetchInstalled).not.toHaveBeenCalled()
})
it('ignores initialization errors', async () => {
vi.mocked(useSystemStatsStore).mockImplementation(() => {
throw new Error('stats unavailable')
})
const { initializeConflictDetection } = useConflictDetection()
await expect(initializeConflictDetection()).resolves.toBeUndefined()
})
})
describe('modal gating', () => {
it('shows the modal after update when conflicts exist and acknowledgment allows it', async () => {
mockConflictedPackages = [
{
package_id: 'conflict-pack',
package_name: 'Conflict Pack',
has_conflict: true,
is_compatible: false,
conflicts: []
}
]
mockShouldShowConflictModal.value = true
mockInstalledPacks.isReady.value = true
mockInstalledPacksWithVersions.value = []
const { shouldShowConflictModalAfterUpdate } = useConflictDetection()
await expect(shouldShowConflictModalAfterUpdate()).resolves.toBe(true)
})
it('keeps the modal hidden when acknowledgment blocks it', async () => {
mockConflictedPackages = [
{
package_id: 'conflict-pack',
package_name: 'Conflict Pack',
has_conflict: true,
is_compatible: false,
conflicts: []
}
]
mockShouldShowConflictModal.value = false
mockInstalledPacks.isReady.value = true
mockInstalledPacksWithVersions.value = []
const { shouldShowConflictModalAfterUpdate } = useConflictDetection()
await expect(shouldShowConflictModalAfterUpdate()).resolves.toBe(false)
})
})
describe('node compatibility', () => {
it('reports compatibility conflicts for registry nodes', async () => {
const { collectSystemEnvironment, checkNodeCompatibility } =
useConflictDetection()
await collectSystemEnvironment()
vi.mocked(checkOSCompatibility).mockReturnValue({
type: 'os',
current_value: 'macOS',
required_value: 'Windows'
})
vi.mocked(checkAcceleratorCompatibility).mockReturnValue({
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
})
vi.mocked(checkVersionCompatibility).mockImplementation((type) => ({
type,
current_value: type,
required_value: '>=9.0.0'
}))
const result = checkNodeCompatibility({
id: 'node-pack',
status: 'NodeStatusBanned',
supported_os: ['Windows'],
supported_accelerators: ['CUDA'],
supported_comfyui_version: '>=9.0.0',
supported_comfyui_frontend_version: '>=9.0.0'
} as components['schemas']['Node'])
expect(result).toEqual({
hasConflict: true,
conflicts: expect.arrayContaining([
{ type: 'os', current_value: 'macOS', required_value: 'Windows' },
{
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
},
{
type: 'comfyui_version',
current_value: 'comfyui_version',
required_value: '>=9.0.0'
},
{
type: 'frontend_version',
current_value: 'frontend_version',
required_value: '>=9.0.0'
},
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
])
})
})
it('reports pending version nodes as incompatible', () => {
const { checkNodeCompatibility } = useConflictDetection()
const result = checkNodeCompatibility({
node_id: 'node-pack',
version: '1.0.0',
publisher_id: 'publisher',
created_at: '2024-01-01T00:00:00Z',
status: 'NodeVersionStatusPending'
} as components['schemas']['NodeVersion'])
expect(result.conflicts).toContainEqual({
type: 'pending',
current_value: 'installed',
required_value: 'not_pending'
})
})
it('reports compatible registry nodes without conflicts', () => {
const { checkNodeCompatibility } = useConflictDetection()
expect(
checkNodeCompatibility({
id: 'node-pack',
status: 'NodeStatusActive'
} as components['schemas']['Node'])
).toEqual({ hasConflict: false, conflicts: [] })
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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