Compare commits

..

6 Commits

Author SHA1 Message Date
huang47
817354af0c refactor: dedupe Firebase mock credential casts in authStore.test.ts
Isolated cleanup, no test-behavior change: extracts the repeated
{ user } as Partial<UserCredential> as UserCredential double-cast
into an asUserCredential() helper.
2026-07-02 14:30:46 -07:00
huang47
95257dbba7 test: keep authStore coverage additions free of unrelated refactor
Coverage-only commit: reverts the incidental asUserCredential() helper
extraction so this PR's diff carries no deletions beyond the one
necessary trailing comma. The helper returns as its own commit next.
2026-07-02 14:28:49 -07:00
huang47
08648920af test: cover session UI stores 2026-07-02 09:00:38 -07:00
Benjamin Lu
2ec2a0e091 feat: attribute payment intent through paywall, checkout, and top-up telemetry (#13363)
## Summary

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

## Changes

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

## Review Focus

- `modal_opened` now fires once per dialog actually shown, so a
free-tier user clicking Upgrade emits two events (free-tier dialog, then
pricing table) where the legacy path emitted one
- Intent is threaded explicitly via props/params rather than shared
state; `useSubscriptionCheckout` gained an optional second parameter
2026-07-02 03:11:21 +00:00
Mobeen Abdullah
9cf5c9a93f refactor(website): tidy customer story review nits (#13324)
## Summary

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

## Changes

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

## Review Focus

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

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

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

- **`Category` type in `ArticleNav`**: kept the `ComponentProps<typeof
CategoryNav>` derivation. AGENTS.md says to derive component types via
`vue-component-type-helpers` rather than redefining them, so the current
form follows the styleguide. Happy to switch to a plain named type if
preferred.
- **Section ids in frontmatter vs the body `<Section>`**: kept the
`customers.content.test.ts` parity test. The short TOC labels live only
in frontmatter and Astro can't introspect the rendered MDX body to build
the nav, so the frontmatter `sections` list and the body anchor ids
can't be trivially deduplicated. A real fix would need a remark plugin
(larger, separate change). The test guards against silent drift in the
meantime.
- **`nextStory` throw**: left as a fail-loud, build-time invariant. The
slug always comes from the same `getStaticPaths` collection, so the
throw is effectively unreachable; it surfaces a future-refactor bug
loudly instead of linking to the wrong story.
2026-07-01 12:45:24 +00:00
jaeone94
9e5fb67b76 Show app mode run validation warning (#12557)
## Summary
Adds an app mode validation warning so users can see when a workflow has
errors before running and jump directly back to graph mode to review
them.

## Changes
- **What**: Adds a reusable app mode warning banner above the Run button
when the execution error store reports workflow errors, including
validation and missing asset states.
- **What**: Reuses the existing graph-error navigation flow so the
warning action switches out of app mode and opens the Errors panel in
graph mode.
- **What**: Updates the app mode Run button icon and accessible label in
the warning state while keeping the Run action non-blocking.
- **What**: Adds unit coverage for the warning render/accessibility
state and an E2E flow that triggers a validation failure, dismisses the
overlay, and opens graph errors from the app mode warning.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus
The warning intentionally mirrors graph mode behavior: it surfaces the
error state but does not prevent the user from clicking Run. This avoids
turning display-level validation signals into hard execution blockers.

The warning is driven by the existing `hasAnyError` aggregate, so
missing nodes, missing models, and missing media are included alongside
prompt/node/execution errors.

## Tests
- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm knip`
- `pnpm test:browser:local
browser_tests/tests/appModeValidationWarning.spec.ts`

## Screenshots

<img width="461" height="994" alt="스크린샷 2026-06-25 오후 7 00 55"
src="https://github.com/user-attachments/assets/f8fc20bf-d572-46b5-9fa4-312e7c4c8076"
/>
2026-07-01 15:24:45 +09:00
91 changed files with 3157 additions and 1329 deletions

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
{
"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,6 +34,10 @@ 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. */
@@ -92,13 +96,19 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.getByTestId('linear-widgets')
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.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId('linear-run-button')
.getByTestId(TestIds.linear.runButton)
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(

View File

@@ -172,6 +172,9 @@ 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

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

View File

@@ -3,6 +3,7 @@ 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 ({
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.linear.runButton)
).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="seeErrors"
@click="viewErrorsInGraph"
>
{{
appMode
@@ -67,31 +67,18 @@ 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 rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { viewErrorsInGraph } = useViewErrorsInGraph()
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()
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
emit('close')
}
@@ -239,8 +239,7 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
dialogService.showTopUpCreditsDialog()
emit('close')
}
@@ -254,7 +253,7 @@ const handleOpenPartnerNodesInfo = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
emit('close')
}

View File

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

View File

@@ -8,7 +8,16 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
const mockAuthStore = vi.hoisted(() => ({
logout: vi.fn().mockResolvedValue(undefined)
logout: vi.fn().mockResolvedValue(undefined),
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
initiateCreditPurchase: vi.fn(),
accessBillingPortal: vi.fn(),
fetchBalance: vi.fn(),
loginWithGoogle: vi.fn(),
loginWithGithub: vi.fn(),
login: vi.fn(),
register: vi.fn(),
updatePassword: vi.fn().mockResolvedValue(undefined)
}))
const mockToastStore = vi.hoisted(() => ({
@@ -29,6 +38,16 @@ const mockDialogService = vi.hoisted(() => ({
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockBillingContext = vi.hoisted(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
const mockTelemetry = vi.hoisted(() => ({
startTopupTracking: vi.fn()
}))
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use'
@@ -48,7 +67,7 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => undefined)
useTelemetry: vi.fn(() => mockTelemetry)
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
@@ -72,11 +91,7 @@ vi.mock('@/stores/authStore', () => ({
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
useBillingContext: vi.fn(() => mockBillingContext)
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -97,6 +112,7 @@ describe('useAuthActions.logout', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockWorkflowStore.modifiedWorkflows = []
mockBillingContext.isActiveSubscription.value = false
})
it('logs out without prompting when no workflows are modified', async () => {
@@ -281,4 +297,158 @@ describe('useAuthActions.reportError', () => {
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
expect(mockToastStore.add).not.toHaveBeenCalled()
})
it('shows the unauthorized-domain access error message', () => {
const { reportError, accessError } = useAuthActions()
reportError(new FirebaseError('auth/unauthorized-domain', 'blocked'))
expect(accessError.value).toBe(true)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'toastMessages.unauthorizedDomain'
})
})
})
describe('useAuthActions account actions', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockBillingContext.isActiveSubscription.value = false
vi.stubGlobal(
'open',
vi.fn(() => ({}))
)
})
it('sends password reset emails and shows success toast', async () => {
const { sendPasswordReset } = useAuthActions()
await sendPasswordReset('user@example.com')
expect(mockAuthStore.sendPasswordReset).toHaveBeenCalledWith(
'user@example.com'
)
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'auth.login.passwordResetSent'
})
)
})
it('does not purchase credits without an active subscription', async () => {
const { purchaseCredits } = useAuthActions()
await purchaseCredits(25)
expect(mockAuthStore.initiateCreditPurchase).not.toHaveBeenCalled()
expect(window.open).not.toHaveBeenCalled()
})
it('opens checkout and tracks top-up starts for credit purchases', async () => {
mockBillingContext.isActiveSubscription.value = true
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({
checkout_url: 'https://checkout.example.test'
})
const { purchaseCredits } = useAuthActions()
await purchaseCredits(25)
expect(mockAuthStore.initiateCreditPurchase).toHaveBeenCalledWith({
amount_micros: 25000000,
currency: 'usd'
})
expect(mockTelemetry.startTopupTracking).toHaveBeenCalledOnce()
expect(window.open).toHaveBeenCalledWith(
'https://checkout.example.test',
'_blank'
)
})
it('throws when credit checkout URL is missing', async () => {
mockBillingContext.isActiveSubscription.value = true
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({})
const { purchaseCredits } = useAuthActions()
await expect(purchaseCredits(10)).rejects.toThrow(
'toastMessages.failedToPurchaseCredits'
)
})
it('opens the billing portal in a new tab by default', async () => {
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({
billing_portal_url: 'https://billing.example.test'
})
const { accessBillingPortal } = useAuthActions()
await expect(accessBillingPortal('pro')).resolves.toBe(true)
expect(mockAuthStore.accessBillingPortal).toHaveBeenCalledWith('pro')
expect(window.open).toHaveBeenCalledWith(
'https://billing.example.test',
'_blank'
)
})
it('throws when billing portal URL is missing', async () => {
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({})
const { accessBillingPortal } = useAuthActions()
await expect(accessBillingPortal()).rejects.toThrow(
'toastMessages.failedToAccessBillingPortal'
)
})
it('delegates balance and sign-in methods to the auth store', async () => {
mockAuthStore.fetchBalance.mockResolvedValueOnce({ balance: 12 })
mockAuthStore.loginWithGoogle.mockResolvedValueOnce('google')
mockAuthStore.loginWithGithub.mockResolvedValueOnce('github')
mockAuthStore.login.mockResolvedValueOnce('email')
mockAuthStore.register.mockResolvedValueOnce('registered')
const actions = useAuthActions()
await expect(actions.fetchBalance()).resolves.toEqual({ balance: 12 })
await expect(actions.signInWithGoogle({ isNewUser: true })).resolves.toBe(
'google'
)
await expect(actions.signInWithGithub({ isNewUser: false })).resolves.toBe(
'github'
)
await expect(actions.signInWithEmail('u@example.com', 'pw')).resolves.toBe(
'email'
)
await expect(
actions.signUpWithEmail('u@example.com', 'pw', 'turnstile')
).resolves.toBe('registered')
expect(mockAuthStore.loginWithGoogle).toHaveBeenCalledWith({
isNewUser: true
})
expect(mockAuthStore.loginWithGithub).toHaveBeenCalledWith({
isNewUser: false
})
expect(mockAuthStore.login).toHaveBeenCalledWith('u@example.com', 'pw')
expect(mockAuthStore.register).toHaveBeenCalledWith(
'u@example.com',
'pw',
'turnstile'
)
})
it('updates passwords and shows success toast', async () => {
const { updatePassword } = useAuthActions()
await updatePassword('new-password')
expect(mockAuthStore.updatePassword).toHaveBeenCalledWith('new-password')
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'auth.passwordUpdate.success'
})
)
})
})

View File

@@ -0,0 +1,213 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import type { User as FirebaseUser } from 'firebase/auth'
import type { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
type FirebaseUserMock = Pick<
FirebaseUser,
'uid' | 'displayName' | 'email' | 'photoURL'
> & {
providerData: Array<Pick<FirebaseUser['providerData'][number], 'providerId'>>
}
type ApiKeyUser = NonNullable<
ReturnType<typeof useApiKeyAuthStore>['currentUser']
>
const mockStores = vi.hoisted(() => ({
authStore: undefined as
| undefined
| {
currentUser: FirebaseUserMock | null
loading: boolean
tokenRefreshTrigger: number
},
apiKeyStore: undefined as
| undefined
| {
isAuthenticated: boolean
currentUser: ApiKeyUser | null
clearStoredApiKey: ReturnType<typeof vi.fn>
},
commandStore: undefined as
| undefined
| {
execute: ReturnType<typeof vi.fn>
}
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => mockStores.authStore
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => mockStores.apiKeyStore
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => mockStores.commandStore
}))
async function setup() {
vi.resetModules()
const authStore = reactive({
currentUser: null as FirebaseUserMock | null,
loading: false,
tokenRefreshTrigger: 0
})
const apiKeyStore = reactive({
isAuthenticated: false,
currentUser: null as ApiKeyUser | null,
clearStoredApiKey: vi.fn()
})
const commandStore = {
execute: vi.fn()
}
mockStores.authStore = authStore
mockStores.apiKeyStore = apiKeyStore
mockStores.commandStore = commandStore
const { useCurrentUser } = await import('./useCurrentUser')
return {
currentUser: useCurrentUser(),
authStore,
apiKeyStore,
commandStore
}
}
function firebaseUser(
providerId: string,
overrides: Partial<FirebaseUserMock> = {}
): FirebaseUserMock {
return {
uid: 'firebase-user',
displayName: 'Firebase User',
email: 'firebase@example.com',
photoURL: 'https://example.com/photo.png',
providerData: [{ providerId }],
...overrides
}
}
describe('useCurrentUser', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('reports logged-out state when no auth source is active', async () => {
const { currentUser } = await setup()
expect(currentUser.loading).toBe(false)
expect(currentUser.isLoggedIn.value).toBe(false)
expect(currentUser.resolvedUserInfo.value).toBeNull()
expect(currentUser.userDisplayName.value).toBeUndefined()
expect(currentUser.userEmail.value).toBeUndefined()
expect(currentUser.userPhotoUrl.value).toBeUndefined()
expect(currentUser.providerName.value).toBeUndefined()
expect(currentUser.providerIcon.value).toBe('pi pi-user')
expect(currentUser.isEmailProvider.value).toBe(false)
})
it('uses API key user identity before firebase identity', async () => {
const { currentUser, authStore, apiKeyStore } = await setup()
authStore.currentUser = firebaseUser('google.com')
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = {
id: 'api-user',
name: 'API User',
email: 'api@example.com'
}
expect(currentUser.isLoggedIn.value).toBe(true)
expect(currentUser.isApiKeyLogin.value).toBe(true)
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'api-user' })
expect(currentUser.userDisplayName.value).toBe('API User')
expect(currentUser.userEmail.value).toBe('api@example.com')
expect(currentUser.userPhotoUrl.value).toBeNull()
expect(currentUser.providerName.value).toBe('Comfy API Key')
expect(currentUser.providerIcon.value).toBe('pi pi-key')
expect(currentUser.isEmailProvider.value).toBe(false)
})
it('maps firebase provider metadata to display fields', async () => {
const { currentUser, authStore } = await setup()
authStore.currentUser = firebaseUser('google.com')
expect(currentUser.providerName.value).toBe('Google')
expect(currentUser.providerIcon.value).toBe('pi pi-google')
expect(currentUser.userDisplayName.value).toBe('Firebase User')
expect(currentUser.userEmail.value).toBe('firebase@example.com')
expect(currentUser.userPhotoUrl.value).toBe('https://example.com/photo.png')
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'firebase-user' })
authStore.currentUser = firebaseUser('github.com')
expect(currentUser.providerName.value).toBe('GitHub')
expect(currentUser.providerIcon.value).toBe('pi pi-github')
authStore.currentUser = firebaseUser('password')
expect(currentUser.providerName.value).toBe('password')
expect(currentUser.providerIcon.value).toBe('pi pi-user')
expect(currentUser.isEmailProvider.value).toBe(true)
})
it('routes sign out through the active auth source', async () => {
const { currentUser, apiKeyStore, commandStore } = await setup()
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = { id: 'api-user' }
await currentUser.handleSignOut()
expect(apiKeyStore.clearStoredApiKey).toHaveBeenCalledOnce()
apiKeyStore.isAuthenticated = false
await currentUser.handleSignOut()
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.User.SignOut')
})
it('opens the sign-in dialog through the command store', async () => {
const { currentUser, commandStore } = await setup()
await currentUser.handleSignIn()
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.User.OpenSignInDialog'
)
})
it('runs user lifecycle callbacks for resolve, token refresh, and logout', async () => {
const { currentUser, authStore } = await setup()
const resolved = vi.fn()
const tokenRefreshed = vi.fn()
const logout = vi.fn()
currentUser.onUserResolved(resolved)
currentUser.onTokenRefreshed(tokenRefreshed)
currentUser.onUserLogout(logout)
authStore.currentUser = firebaseUser('google.com')
await nextTick()
expect(resolved.mock.calls[0][0]).toEqual({ id: 'firebase-user' })
authStore.tokenRefreshTrigger += 1
await nextTick()
expect(tokenRefreshed).toHaveBeenCalledOnce()
authStore.currentUser = null
await nextTick()
expect(logout).toHaveBeenCalledOnce()
})
it('runs onUserResolved immediately when a user already exists', async () => {
const { currentUser, apiKeyStore } = await setup()
apiKeyStore.isAuthenticated = true
apiKeyStore.currentUser = { id: 'api-user' }
const resolved = vi.fn()
currentUser.onUserResolved(resolved)
expect(resolved.mock.calls[0][0]).toEqual({ id: 'api-user' })
})
})

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,7 +38,6 @@ export interface BalanceInfo {
effectiveBalanceMicros?: number
prepaidBalanceMicros?: number
cloudCreditBalanceMicros?: number
pendingChargesMicros?: number
}
export interface BillingActions {
@@ -77,9 +76,10 @@ export interface BillingActions {
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog.
* Shows the subscription dialog. Pass a reason so the paywall open and any
* downstream checkout stay attributed to the triggering product moment.
*/
showSubscriptionDialog: () => void
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
}
export interface BillingState {
@@ -103,10 +103,6 @@ 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,6 +7,7 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
SubscribeOptions
@@ -158,12 +159,6 @@ 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
@@ -287,8 +282,8 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
return activeContext.value.showSubscriptionDialog(options)
}
return {
@@ -309,8 +304,6 @@ function useBillingContextInternal(): BillingContext {
subscriptionStatus,
tier,
renewalDate,
paymentMethodCapability,
defaultPaymentMethodType,
getMaxSeats,
initialize,

View File

@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingStatus,
BillingSubscriptionStatus,
@@ -91,9 +92,6 @@ 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(() => [])
@@ -192,12 +190,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog()
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
}
}
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
legacyShowSubscriptionDialog(options)
}
return {
@@ -217,8 +215,6 @@ 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()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}

View File

@@ -30,8 +30,7 @@ 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',
SETTLE_ENDPOINT_ENABLED = 'settle_endpoint_enabled'
SIGNUP_TURNSTILE = 'signup_turnstile'
}
/**
@@ -182,13 +181,6 @@ export function useFeatureFlags() {
remoteConfig.value.signup_turnstile,
'off'
)
},
get settleEndpointEnabled() {
return resolveFlag(
ServerFeatureFlag.SETTLE_ENDPOINT_ENABLED,
undefined,
false
)
}
})

View File

@@ -0,0 +1,105 @@
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

@@ -0,0 +1,22 @@
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,11 +2506,7 @@
"topupFailed": "Top-up failed",
"topupTimeout": "Top-up verification timed out",
"cancelFailed": "Failed to cancel subscription",
"cancelTimeout": "Subscription cancellation timed out",
"payOwedProcessing": "Processing payment…",
"payOwedSuccess": "Payment processed successfully",
"payOwedFailed": "Payment failed",
"payOwedTimeout": "Payment verification timed out"
"cancelTimeout": "Subscription cancellation timed out"
},
"subscription": {
"plansForWorkspace": "Plans for {workspace}",
@@ -4493,37 +4489,5 @@
"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()
showSubscriptionDialog({ reason: 'upload_model_upgrade' })
}
</script>

View File

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

View File

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

View File

@@ -10,16 +10,12 @@ import type { CurrentTeamCreditStop } from '@/platform/workspace/api/workspaceAp
type Balance = Pick<
BalanceInfo,
| 'amountMicros'
| 'cloudCreditBalanceMicros'
| 'prepaidBalanceMicros'
| 'pendingChargesMicros'
> & { currency?: string }
'amountMicros' | 'cloudCreditBalanceMicros' | 'prepaidBalanceMicros'
>
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,
@@ -29,20 +25,12 @@ 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(),
toastAdd: vi.fn(),
initiateAddPaymentMethod: vi.fn(),
settleOwedBalance: vi.fn(),
startOperation: vi.fn(),
clearOperation: vi.fn()
toastErrorHandler: vi.fn()
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -69,46 +57,11 @@ 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 }))
@@ -139,11 +92,6 @@ const i18n = createI18n({
locale: 'en',
messages: {
en: {
g: {
error: 'Error',
warning: 'Warning',
unknownError: 'An unknown error occurred'
},
subscription: {
totalCredits: 'Total credits',
remaining: 'remaining',
@@ -168,25 +116,7 @@ const i18n = createI18n({
outOfCreditsTitleNoDate: "You're out of credits",
outOfCreditsDescription: 'Add more credits to continue generating.',
addCredits: '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.'
}
upgradeToAddCredits: 'Upgrade to add credits'
}
}
}
@@ -201,17 +131,11 @@ function renderTile(props: Record<string, unknown> = {}) {
stubs: {
Button: {
template:
'<button v-bind="$attrs" :data-variant="variant" :disabled="disabled || loading" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading', 'disabled'],
'<button v-bind="$attrs" :data-variant="variant" :disabled="loading" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading'],
emits: ['click']
},
Skeleton: {
template: '<div role="status" aria-label="Loading"></div>'
},
SubscriptionTermsNote: {
template: '<p data-testid="terms-note" :data-context="context"></p>',
props: ['context']
}
Skeleton: { template: '<div role="status" aria-label="Loading"></div>' }
}
}
})
@@ -241,9 +165,6 @@ describe('CreditsTile', () => {
state.currentTeamCreditStop = null
state.isLoading = false
state.canTopUp = true
state.paymentMethodCapability = null
state.settleEndpointEnabled = false
state.isPayingOwed = false
vi.clearAllMocks()
})
@@ -449,138 +370,4 @@ 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,7 +3,6 @@
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"
@@ -144,94 +143,6 @@
</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"
@@ -265,14 +176,13 @@
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Skeleton from 'primevue/skeleton'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, onMounted } 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 {
@@ -283,11 +193,7 @@ 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<{
@@ -303,7 +209,6 @@ const {
isActiveSubscription,
isFreeTier,
currentTeamCreditStop,
paymentMethodCapability,
fetchBalance,
fetchStatus
} = useBillingContext()
@@ -320,101 +225,6 @@ 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
@@ -541,12 +351,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => {
})
function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked()
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
void dialogService.showTopUpCreditsDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable()
showPricingTable({ reason: 'upgrade_to_add_credits' })
}
async function handleWindowFocus() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -258,8 +258,6 @@ export interface CurrentTeamCreditStop {
stop_usd: number
}
export type PaymentMethodCapability = 'none' | 'one_time_only' | 'reusable'
export interface BillingStatusResponse {
is_active: boolean
subscription_status?: BillingSubscriptionStatus
@@ -271,8 +269,6 @@ 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 {
@@ -289,14 +285,13 @@ interface CreateTopupRequest {
idempotency_key?: string
}
type TopupStatus = 'pending' | 'completed' | 'failed' | 'needs_payment_method'
type TopupStatus = 'pending' | 'completed' | 'failed'
export interface CreateTopupResponse {
billing_op_id: string
topup_id: string
status: TopupStatus
amount_cents: number
payment_method_url?: string
}
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
@@ -329,15 +324,6 @@ 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,
@@ -804,43 +790,5 @@ 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()
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
emit('close')
}
@@ -336,13 +336,12 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
emit('close')
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
dialogService.showTopUpCreditsDialog()
emit('close')
}

View File

@@ -1,192 +0,0 @@
<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,22 +38,17 @@ function previewFixture(
}
}
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')
})
}
})
vi.mock('vue-i18n', () => ({
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,12 +391,13 @@ const showZeroState = computed(
)
function handleSubscribeWorkspace() {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'settings_billing_panel' })
}
function handleUpgrade() {
if (isFreeTierPlan.value) showPricingTable()
else showSubscriptionDialog()
if (isFreeTierPlan.value)
showPricingTable({ reason: 'settings_billing_panel' })
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
}
function handleViewMoreDetails() {

View File

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

View File

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

View File

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

View File

@@ -1,54 +1,26 @@
<template>
<p class="m-0 text-center text-xs text-muted-foreground">
<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>
<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>
</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,16 +9,12 @@ import type {
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
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')
})
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}))
const globalOptions = {
mocks: { $t: (key: string) => key },

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { computed, ref, shallowRef } from 'vue'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingBalanceResponse,
BillingStatusResponse,
@@ -70,8 +71,7 @@ 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,
pendingChargesMicros: data.pending_charges_micros
cloudCreditBalanceMicros: data.cloud_credit_balance_micros ?? 0
}
})
@@ -81,12 +81,6 @@ 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(
@@ -282,12 +276,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
subscriptionDialog.show()
subscriptionDialog.show({ reason: 'subscription_required' })
}
}
function showSubscriptionDialog(): void {
subscriptionDialog.show()
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
subscriptionDialog.show(options)
}
return {
@@ -307,8 +301,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
subscriptionStatus,
tier,
renewalDate,
paymentMethodCapability,
defaultPaymentMethodType,
// Actions
initialize,

View File

@@ -504,161 +504,6 @@ 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' | 'pay_owed'
type OperationType = 'subscription' | 'topup' | 'cancel'
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
interface BillingOperation {
@@ -53,12 +53,6 @@ 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)
}
@@ -87,9 +81,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
const messageKey =
type === 'subscription'
? 'billingOperation.subscriptionProcessing'
: type === 'topup'
? 'billingOperation.topupProcessing'
: 'billingOperation.payOwedProcessing'
: 'billingOperation.topupProcessing'
const toastMessage: ToastMessageOptions = {
severity: 'info',
@@ -177,17 +169,6 @@ 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') {
@@ -252,7 +233,6 @@ 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')
}
@@ -260,7 +240,6 @@ 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')
}
@@ -316,7 +295,6 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
hasPendingOperations,
isSettingUp,
isAddingCredits,
isPayingOwed,
getOperation,
startOperation,
clearOperation

View File

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

View File

@@ -0,0 +1,208 @@
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,10 +1,11 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, useTemplateRef } from 'vue'
import { computed, ref, toValue, 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'
@@ -14,11 +15,15 @@ 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())
@@ -28,6 +33,8 @@ 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
@@ -43,6 +50,13 @@ 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
@@ -134,9 +148,10 @@ function handleDragDrop() {
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
data-testid="linear-run-button"
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
@@ -166,18 +181,24 @@ function handleDragDrop() {
variant="primary"
class="grow"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</section>
<section
v-else
data-testid="linear-run-button"
:data-testid="linearRunButtonTestId"
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')"
@@ -198,9 +219,14 @@ 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 class="icon-[lucide--play]" />
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</section>

View File

@@ -0,0 +1,92 @@
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

@@ -0,0 +1,63 @@
<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

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

View File

@@ -18,11 +18,8 @@ import type {
} from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PaymentMethodCapability,
WorkspaceRole
} from '@/platform/workspace/api/workspaceApi'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
// Lazy loaders for dialogs - components are loaded on first use
const lazyApiNodesSignInContent = () =>
@@ -445,9 +442,9 @@ export const useDialogService = () => {
})
}
async function showSubscriptionRequiredDialog(options?: {
reason?: SubscriptionDialogReason
}) {
async function showSubscriptionRequiredDialog(
options?: SubscriptionDialogOptions
) {
if (!isCloud || !window.__CONFIG__?.subscription_required) {
return
}
@@ -632,25 +629,6 @@ 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
@@ -756,7 +734,6 @@ export const useDialogService = () => {
showInviteMemberUpsellDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog,
showDowngradeToPersonalDialog,
showSpendLimitDialog
showDowngradeToPersonalDialog
}
}

View File

@@ -0,0 +1,26 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useExtensionStore } from '@/stores/extensionStore'
describe('actionBarButtonStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('collects action bar buttons from registered extensions', () => {
const extensionStore = useExtensionStore()
const onClick = vi.fn()
extensionStore.registerExtension({
name: 'buttons',
actionBarButtons: [{ icon: 'icon-[lucide--plus]', onClick }]
})
extensionStore.registerExtension({ name: 'plain' })
const store = useActionBarButtonStore()
expect(store.buttons).toEqual([{ icon: 'icon-[lucide--plus]', onClick }])
})
})

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { nextTick, reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -56,9 +56,13 @@ vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({
resolveNode: mockResolveNode
}))
const mockCanvas = vi.hoisted(() => ({
state: undefined as { readOnly: boolean } | undefined
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => ({ read_only: false })
getCanvas: () => ({ state: mockCanvas.state })
})
}))
@@ -162,6 +166,7 @@ describe('appModeStore', () => {
ChangeTracker.isLoadingGraph = false
mockResolveNode.mockReturnValue(undefined)
mockSettings.reset()
mockCanvas.state = undefined
vi.mocked(app.rootGraph).nodes = [{ id: toNodeId(1) } as LGraphNode]
workflowStore = useWorkflowStore()
store = useAppModeStore()
@@ -365,6 +370,83 @@ describe('appModeStore', () => {
expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']])
})
it('keeps canonical entity ids when the node still exists', () => {
const node1 = nodeWithWidgets(1, [])
vi.mocked(app.rootGraph).nodes = [node1]
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
id === toNodeId(1) ? node1 : null
)
store.loadSelections({
inputs: [[entityPrompt, 'prompt']]
})
expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']])
})
it('drops canonical entity ids when their node is gone', () => {
vi.mocked(app.rootGraph).nodes = []
vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null)
store.loadSelections({
inputs: [[entityPrompt, 'prompt']]
})
expect(store.selectedInputs).toEqual([])
})
it('drops locator inputs when the widget does not resolve', () => {
const hostLocator = `${rootGraphId}:5`
const hostNode = fromAny<LGraphNode, unknown>({
id: 5,
isSubgraphNode: () => false,
widgets: [{ name: 'other' }]
})
vi.mocked(app.rootGraph).nodes = [hostNode]
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
id === toNodeId(5) ? hostNode : null
)
store.loadSelections({
inputs: [[hostLocator, 'prompt']]
})
expect(store.selectedInputs).toEqual([])
})
it('drops malformed legacy input ids', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(app.rootGraph).nodes = []
store.loadSelections({
inputs: [[fromAny<SerializedNodeId, unknown>(null), 'prompt']]
})
expect(store.selectedInputs).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('legacy selectedInput tuple'),
expect.objectContaining({ storedId: null, widgetName: 'prompt' })
)
warnSpy.mockRestore()
})
it('drops direct node inputs when the widget is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node1 = nodeWithWidgets(1, [])
vi.mocked(app.rootGraph).nodes = [node1]
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
id === toNodeId(1) ? node1 : null
)
store.loadSelections({
inputs: [[1, 'prompt']]
})
expect(store.selectedInputs).toEqual([])
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it('drops legacy entries whose widget no longer exists', () => {
const node1 = nodeWithWidgets(1, ['prompt'])
vi.mocked(app.rootGraph).nodes = [node1]
@@ -399,6 +481,32 @@ describe('appModeStore', () => {
expect(store.selectedOutputs).toEqual([toNodeId(1)])
})
it('drops malformed output ids on load', () => {
store.loadSelections({
outputs: [fromAny<SerializedNodeId, unknown>('')]
})
expect(store.selectedOutputs).toEqual([])
})
it('drops legacy subgraph input slots without widget ids', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const hostNode = Object.assign(Object.create(SubgraphNode.prototype), {
id: 5,
inputs: [{ name: 'Prompt' }]
})
vi.mocked(app.rootGraph).nodes = [hostNode]
vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null)
store.loadSelections({
inputs: [[1, 'prompt']]
})
expect(store.selectedInputs).toEqual([])
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it('reloads selections on configured event', async () => {
const node1 = nodeWithWidgets(1, ['seed'])
@@ -481,7 +589,7 @@ describe('appModeStore', () => {
expect(
store.pruneLinearData({
inputs: [[1, 'seed']],
outputs: [toNodeId(1)]
outputs: [toNodeId(1), fromAny<SerializedNodeId, unknown>('')]
})
).toEqual({
inputs: [[1, 'seed']],
@@ -641,6 +749,17 @@ describe('appModeStore', () => {
expect(originalRootGraph.extra.linearData).toEqual(dataBefore)
})
it('does not write while graph loading is in progress', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
ChangeTracker.isLoadingGraph = true
await nextTick()
store.selectedOutputs.push(toNodeId(1))
await nextTick()
expect(app.rootGraph.extra.linearData).toBeUndefined()
})
it('calls captureCanvasState when input is selected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
@@ -755,6 +874,24 @@ describe('appModeStore', () => {
expect(store.selectedInputs).toEqual([[promptEntity, 'prompt']])
})
it('ignores widgets without ids', () => {
store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt'])
store.removeSelectedInput(fromAny<IBaseWidget, unknown>({}))
expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']])
})
it('ignores missing input ids', () => {
store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt'])
store.removeSelectedInput(
fromAny<IBaseWidget, unknown>({ widgetId: 'g:2:prompt' })
)
expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']])
})
})
describe('autoEnableVueNodes', () => {
@@ -819,6 +956,47 @@ describe('appModeStore', () => {
expect.anything()
)
})
it('does not enable Vue nodes after leaving select mode', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
mockSettings.set.mockClear()
store.exitBuilder()
await nextTick()
expect(mockSettings.set).not.toHaveBeenCalled()
})
})
describe('read only canvas sync', () => {
it('keeps canvas read-only while in select mode', async () => {
mockCanvas.state = reactive({ readOnly: false })
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
mockCanvas.state.readOnly = false
await nextTick()
expect(mockCanvas.state.readOnly).toBe(true)
})
it('stops enforcing read-only after leaving select mode', async () => {
mockCanvas.state = reactive({ readOnly: false })
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
store.exitBuilder()
await nextTick()
mockCanvas.state.readOnly = false
await nextTick()
expect(mockCanvas.state.readOnly).toBe(false)
})
})
describe('legacy selectedInput tuple migration', () => {
@@ -907,6 +1085,121 @@ describe('appModeStore', () => {
])
})
it('drops direct root-node widgets that cannot produce an entity id', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const sourceNodeId = 42
const sourceWidgetName = 'text'
const rootNode = fromAny<LGraphNode, unknown>({
id: sourceNodeId,
widgets: [{ name: sourceWidgetName }]
})
vi.mocked(app.rootGraph).id = rootGraphId
vi.mocked(app.rootGraph).nodes = [rootNode]
vi.mocked(app.rootGraph).getNodeById = vi.fn(
(id: SerializedNodeId | null | undefined) =>
id == sourceNodeId ? rootNode : null
)
const result = store.pruneLinearData({
inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]],
outputs: []
})
expect(result.inputs).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('legacy selectedInput tuple'),
expect.objectContaining({
storedId: sourceNodeId,
widgetName: sourceWidgetName
})
)
warnSpy.mockRestore()
})
it('drops promoted inputs whose source target no longer matches', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraphInputName = 'Prompt'
const sourceWidgetName = 'text'
const subgraph = createTestSubgraph({
inputs: [{ name: subgraphInputName, type: 'STRING' }]
})
const interior = new LGraphNodeClass('Interior')
const interiorInput = interior.addInput(subgraphInputName, 'STRING')
interior.addWidget('string', sourceWidgetName, '', () => undefined)
interiorInput.widget = { name: sourceWidgetName }
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interiorInput, interior)
const host = createTestSubgraphNode(subgraph, { id: 5 })
const rootGraph = host.graph as LGraph
rootGraph.add(host)
host._internalConfigureAfterSlots()
vi.mocked(app.rootGraph).id = rootGraph.id
vi.mocked(app.rootGraph).nodes = rootGraph.nodes
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
rootGraph.getNodeById(id)
)
const result = store.pruneLinearData({
inputs: [[interior.id, 'other-widget', { height: 120 }]],
outputs: []
})
expect(result.inputs).toEqual([])
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it('drops legacy inputs when multiple promoted inputs match', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraphInputName = 'Prompt'
const sourceWidgetName = 'text'
const subgraph = createTestSubgraph({
inputs: [{ name: subgraphInputName, type: 'STRING' }]
})
const interior = new LGraphNodeClass('Interior')
const interiorInput = interior.addInput(subgraphInputName, 'STRING')
interior.addWidget('string', sourceWidgetName, '', () => undefined)
interiorInput.widget = { name: sourceWidgetName }
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interiorInput, interior)
const firstHost = createTestSubgraphNode(subgraph, { id: 5 })
const rootGraph = firstHost.graph as LGraph
const secondHost = createTestSubgraphNode(subgraph, {
id: 6,
parentGraph: rootGraph
})
rootGraph.add(firstHost)
rootGraph.add(secondHost)
firstHost._internalConfigureAfterSlots()
secondHost._internalConfigureAfterSlots()
vi.mocked(app.rootGraph).id = rootGraph.id
vi.mocked(app.rootGraph).nodes = rootGraph.nodes
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
rootGraph.getNodeById(id)
)
const result = store.pruneLinearData({
inputs: [[interior.id, sourceWidgetName, { height: 120 }]],
outputs: []
})
expect(result.inputs).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('ambiguous legacy selectedInput tuple'),
expect.objectContaining({
storedId: interior.id,
widgetName: sourceWidgetName
})
)
warnSpy.mockRestore()
})
it('warns and drops a tuple whose target widget no longer resolves', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(app.rootGraph).id = rootGraphId

View File

@@ -37,6 +37,14 @@ type MockUser = Omit<User, 'getIdToken' | 'delete'> & {
type MockAuth = Record<string, unknown>
/**
* Centralizes the type-boundary double-cast for Firebase mock credentials
* so individual tests only deal with the mock user.
*/
function asUserCredential(user: Partial<MockUser>): UserCredential {
return { user } as Partial<UserCredential> as UserCredential
}
// Mock fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
@@ -90,6 +98,7 @@ vi.mock('firebase/auth', async (importOriginal) => {
onAuthStateChanged: vi.fn(),
onIdTokenChanged: vi.fn(),
signInWithPopup: vi.fn(),
sendPasswordResetEmail: vi.fn(),
GoogleAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
@@ -99,7 +108,8 @@ vi.mock('firebase/auth', async (importOriginal) => {
setCustomParameters = vi.fn()
},
getAdditionalUserInfo: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined)
setPersistence: vi.fn().mockResolvedValue(undefined),
updatePassword: vi.fn()
}
})
@@ -127,6 +137,18 @@ vi.mock('@/composables/useFeatureFlags', () => ({
})
}))
const mockWorkspaceAuthStore = vi.hoisted(() => ({
unifiedToken: null as string | null,
clearWorkspaceContext: vi.fn(),
mintAtLogin: vi.fn(),
getWorkspaceAuthHeader: vi.fn(),
getWorkspaceToken: vi.fn()
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
}))
// Mock apiKeyAuthStore
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
vi.mock('@/stores/apiKeyAuthStore', () => ({
@@ -163,6 +185,9 @@ describe('useAuthStore', () => {
mockFeatureFlags.teamWorkspacesEnabled = false
mockFeatureFlags.unifiedCloudAuthEnabled = false
mockWorkspaceAuthStore.unifiedToken = null
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue(null)
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(undefined)
// Setup dialog service mock
vi.mocked(useDialogService, { partial: true }).mockReturnValue({
@@ -275,6 +300,11 @@ describe('useAuthStore', () => {
store.notifyTokenRefreshed()
expect(store.tokenRefreshTrigger).toBe(1)
})
it('ignores null ID token events', () => {
idTokenCallback?.(null)
expect(store.tokenRefreshTrigger).toBe(0)
})
})
it('should initialize with the current user', () => {
@@ -292,6 +322,27 @@ describe('useAuthStore', () => {
)
})
it('mints workspace auth on cloud login and clears it on logout state', () => {
expect(mockWorkspaceAuthStore.mintAtLogin).toHaveBeenCalledOnce()
authStateCallback(null)
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalledOnce()
})
it('does not mint workspace auth outside cloud', () => {
mockWorkspaceAuthStore.mintAtLogin.mockClear()
mockDistributionTypes.isCloud = false
try {
authStateCallback(mockUser)
expect(mockWorkspaceAuthStore.mintAtLogin).not.toHaveBeenCalled()
} finally {
mockDistributionTypes.isCloud = true
}
})
it('should properly clean up error state between operations', async () => {
// First, cause an error
const mockError = new Error('Invalid password')
@@ -306,18 +357,18 @@ describe('useAuthStore', () => {
}
// Now, succeed on next attempt
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValueOnce({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValueOnce(
asUserCredential(mockUser)
)
await store.login('test@example.com', 'correct-password')
})
describe('login', () => {
it('should login with valid credentials', async () => {
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const result = await store.login('test@example.com', 'password')
@@ -349,11 +400,35 @@ describe('useAuthStore', () => {
expect(store.loading).toBe(false)
})
it('tracks login when Firebase returns no email', async () => {
const userWithoutEmail = { ...mockUser, email: null }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
asUserCredential(userWithoutEmail)
)
await store.login('test@example.com', 'password')
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ email: undefined })
)
})
it('fails customer creation when the signed-in user has no token yet', async () => {
authStateCallback(null)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
await expect(store.login('test@example.com', 'password')).rejects.toThrow(
'Cannot create customer: User not authenticated'
)
})
it('should handle concurrent login attempts correctly', async () => {
// Set up multiple login promises
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const loginPromise1 = store.login('user1@example.com', 'password1')
@@ -369,9 +444,9 @@ describe('useAuthStore', () => {
describe('register', () => {
it('should register a new user', async () => {
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const result = await store.register('new@example.com', 'password')
@@ -404,9 +479,9 @@ describe('useAuthStore', () => {
})
it('forwards the turnstile token to createCustomer as turnstile_token', async () => {
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
await store.register('new@example.com', 'password', 'turnstile-abc')
@@ -420,9 +495,9 @@ describe('useAuthStore', () => {
})
it('omits the request body when no turnstile token is provided', async () => {
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
await store.register('new@example.com', 'password')
@@ -433,9 +508,9 @@ describe('useAuthStore', () => {
})
it('rolls back the orphaned Firebase user when customer creation fails', async () => {
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
// The server-side customer creation (where Turnstile is validated) fails.
mockFetch.mockImplementation((url: string) =>
url.endsWith('/customers')
@@ -456,9 +531,9 @@ describe('useAuthStore', () => {
})
it('does not delete the user on a successful registration', async () => {
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
await store.register('new@example.com', 'password')
@@ -468,9 +543,9 @@ describe('useAuthStore', () => {
it('does not delete an existing user when customer creation fails during login', async () => {
// Regression guard: the rollback must be scoped to register only — login
// signs in an EXISTING user, so a customer hiccup must never delete it.
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
asUserCredential(mockUser)
)
mockFetch.mockImplementation((url: string) =>
url.endsWith('/customers')
? Promise.resolve({
@@ -486,6 +561,19 @@ describe('useAuthStore', () => {
).rejects.toThrow()
expect(mockUser.delete).not.toHaveBeenCalled()
})
it('tracks registration when Firebase returns no email', async () => {
const userWithoutEmail = { ...mockUser, email: null }
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
asUserCredential(userWithoutEmail)
)
await store.register('new@example.com', 'password')
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ email: undefined })
)
})
})
describe('logout', () => {
@@ -530,9 +618,9 @@ describe('useAuthStore', () => {
it('should return null for token after login and logout sequence', async () => {
// Setup mock for login
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
// Login
@@ -619,14 +707,62 @@ describe('useAuthStore', () => {
const authHeader = await store.getAuthHeader()
expect(authHeader).toBeNull() // Should fallback gracefully
})
it('uses the unified cloud token when enabled', async () => {
mockFeatureFlags.unifiedCloudAuthEnabled = true
mockWorkspaceAuthStore.unifiedToken = 'unified-token'
await expect(store.getAuthHeader()).resolves.toEqual({
Authorization: 'Bearer unified-token'
})
await expect(store.getAuthToken()).resolves.toBe('unified-token')
})
it('returns no unified auth when the unified token is missing', async () => {
mockFeatureFlags.unifiedCloudAuthEnabled = true
mockWorkspaceAuthStore.unifiedToken = null
await expect(store.getAuthHeader()).resolves.toBeNull()
await expect(store.getAuthToken()).resolves.toBeUndefined()
})
it('prefers workspace auth when team workspaces are enabled', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue({
Authorization: 'Bearer workspace-header'
})
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(
'workspace-token'
)
await expect(store.getAuthHeader()).resolves.toEqual({
Authorization: 'Bearer workspace-header'
})
await expect(store.getAuthToken()).resolves.toBe('workspace-token')
})
it('falls back to Firebase when workspace auth is unavailable', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue(null)
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(undefined)
await expect(store.getAuthHeader()).resolves.toEqual({
Authorization: 'Bearer mock-id-token'
})
await expect(store.getAuthToken()).resolves.toBe('mock-id-token')
})
it('returns the Firebase token by default', async () => {
await expect(store.getAuthToken()).resolves.toBe('mock-id-token')
})
})
describe('social authentication', () => {
describe('loginWithGoogle', () => {
it('should sign in with Google', async () => {
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const result = await store.loginWithGoogle()
@@ -640,9 +776,9 @@ describe('useAuthStore', () => {
})
it('never sends a turnstile_token on the customer request (OAuth is exempt)', async () => {
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
asUserCredential(mockUser)
)
await store.loginWithGoogle()
@@ -671,9 +807,9 @@ describe('useAuthStore', () => {
describe('loginWithGithub', () => {
it('should sign in with Github', async () => {
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const result = await store.loginWithGithub()
@@ -687,9 +823,9 @@ describe('useAuthStore', () => {
})
it('never sends a turnstile_token on the customer request (OAuth is exempt)', async () => {
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
asUserCredential(mockUser)
)
await store.loginWithGithub()
@@ -717,9 +853,9 @@ describe('useAuthStore', () => {
})
it('should handle concurrent social login attempts correctly', async () => {
const mockUserCredential = { user: mockUser }
const mockUserCredential = asUserCredential(mockUser)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential as Partial<UserCredential> as UserCredential
mockUserCredential
)
const googleLoginPromise = store.loginWithGoogle()
@@ -731,9 +867,7 @@ describe('useAuthStore', () => {
})
describe('sign-up telemetry OR logic', () => {
const mockUserCredential = {
user: mockUser
} as Partial<UserCredential> as UserCredential
const mockUserCredential = asUserCredential(mockUser)
beforeEach(() => {
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
@@ -804,6 +938,22 @@ describe('useAuthStore', () => {
)
}
)
it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
'%s should track undefined email when Firebase returns no email',
async (method) => {
const userWithoutEmail = { ...mockUser, email: null }
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
asUserCredential(userWithoutEmail)
)
await store[method]()
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ email: undefined })
)
}
)
})
})
@@ -975,6 +1125,61 @@ describe('useAuthStore', () => {
await expect(store.accessBillingPortal()).rejects.toThrow()
})
it('throws when no auth method is available', async () => {
authStateCallback(null)
mockApiKeyGetAuthHeader.mockReturnValue(null)
await expect(store.accessBillingPortal()).rejects.toMatchObject({
name: 'AuthStoreError',
message: 'toastMessages.userNotAuthenticated'
})
})
})
describe('fetchBalance', () => {
it('stores the balance and update time when fetching succeeds', async () => {
await expect(store.fetchBalance()).resolves.toEqual({ balance: 0 })
expect(store.balance).toEqual({ balance: 0 })
expect(store.lastBalanceUpdateTime).toBeInstanceOf(Date)
expect(store.isFetchingBalance).toBe(false)
})
it('throws when no auth method is available', async () => {
authStateCallback(null)
mockApiKeyGetAuthHeader.mockReturnValue(null)
await expect(store.fetchBalance()).rejects.toMatchObject({
name: 'AuthStoreError',
message: 'toastMessages.userNotAuthenticated'
})
expect(store.isFetchingBalance).toBe(false)
})
it('returns null when the customer balance is missing', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404
})
await expect(store.fetchBalance()).resolves.toBeNull()
expect(store.balance).toBeNull()
expect(store.isFetchingBalance).toBe(false)
})
it('throws API errors when fetching balance fails', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.resolve({ message: 'Balance unavailable' })
})
await expect(store.fetchBalance()).rejects.toThrow(
'toastMessages.failedToFetchBalance'
)
expect(store.isFetchingBalance).toBe(false)
})
})
describe('getAuthHeaderOrThrow', () => {
@@ -1062,5 +1267,117 @@ describe('useAuthStore', () => {
expect(error).toBeInstanceOf(AuthStoreError)
expect((error as AuthStoreError).status).toBe(422)
})
it('throws when the response has no customer id', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({})
})
await expect(store.createCustomer()).rejects.toThrow(
'toastMessages.failedToCreateCustomer'
)
})
})
describe('password actions', () => {
it('sends password reset emails', async () => {
vi.mocked(firebaseAuth.sendPasswordResetEmail).mockResolvedValue()
await store.sendPasswordReset('test@example.com')
expect(firebaseAuth.sendPasswordResetEmail).toHaveBeenCalledWith(
mockAuth,
'test@example.com'
)
})
it('updates the current user password', async () => {
vi.mocked(firebaseAuth.updatePassword).mockResolvedValue()
await store.updatePassword('new-password')
expect(firebaseAuth.updatePassword).toHaveBeenCalledWith(
mockUser,
'new-password'
)
})
it('throws when updating password without a user', async () => {
authStateCallback(null)
await expect(store.updatePassword('new-password')).rejects.toMatchObject({
name: 'AuthStoreError',
message: 'toastMessages.userNotAuthenticated'
})
})
})
describe('initiateCreditPurchase', () => {
it('creates the customer once before adding credits', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.endsWith('/customers')) {
return Promise.resolve(mockCreateCustomerResponse)
}
if (url.endsWith('/customers/credit')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ redirect_url: 'https://stripe.test' })
})
}
return Promise.reject(new Error('Unexpected API call'))
})
await store.initiateCreditPurchase({
amount_micros: 10_000_000,
currency: 'usd'
})
await store.initiateCreditPurchase({
amount_micros: 10_000_000,
currency: 'usd'
})
const customerCalls = mockFetch.mock.calls.filter(([url]) =>
String(url).endsWith('/customers')
)
expect(customerCalls).toHaveLength(1)
})
it('throws when credit purchase fails', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.endsWith('/customers')) {
return Promise.resolve(mockCreateCustomerResponse)
}
if (url.endsWith('/customers/credit')) {
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ message: 'Checkout unavailable' })
})
}
return Promise.reject(new Error('Unexpected API call'))
})
await expect(
store.initiateCreditPurchase({
amount_micros: 10_000_000,
currency: 'usd'
})
).rejects.toThrow('toastMessages.failedToInitiateCreditPurchase')
})
it('throws when no auth method is available', async () => {
authStateCallback(null)
mockApiKeyGetAuthHeader.mockReturnValue(null)
await expect(
store.initiateCreditPurchase({
amount_micros: 10_000_000,
currency: 'usd'
})
).rejects.toMatchObject({
name: 'AuthStoreError',
message: 'toastMessages.userNotAuthenticated'
})
})
})
})

View File

@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useBootstrapStore } from './bootstrapStore'
@@ -21,25 +22,28 @@ vi.mock('@/i18n', () => ({
}))
const mockIsSettingsReady = ref(false)
const mockSettingStore = {
load: vi.fn(() => {
mockIsSettingsReady.value = true
}),
get isReady() {
return mockIsSettingsReady.value
},
isLoading: ref(false),
error: ref(undefined)
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
load: vi.fn(() => {
mockIsSettingsReady.value = true
}),
get isReady() {
return mockIsSettingsReady.value
},
isLoading: ref(false),
error: ref(undefined)
}))
useSettingStore: vi.fn(() => mockSettingStore)
}))
const mockWorkflowStore = {
loadWorkflows: vi.fn(),
syncWorkflows: vi.fn().mockResolvedValue(undefined)
}
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
loadWorkflows: vi.fn(),
syncWorkflows: vi.fn().mockResolvedValue(undefined)
}))
useWorkflowStore: vi.fn(() => mockWorkflowStore)
}))
const mockNeedsLogin = ref(false)
@@ -93,6 +97,21 @@ describe('bootstrapStore', () => {
})
})
it('does not reload authenticated stores after bootstrap already ran', async () => {
const store = useBootstrapStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
await store.startStoreBootstrap()
await store.startStoreBootstrap()
await vi.waitFor(() => {
expect(store.isI18nReady).toBe(true)
})
expect(settingStore.load).toHaveBeenCalledOnce()
expect(workflowStore.loadWorkflows).toHaveBeenCalledOnce()
})
describe('cloud mode', () => {
beforeEach(() => {
mockDistributionTypes.isCloud = true

View File

@@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCommandStore } from '@/stores/commandStore'
const keybindingMock = vi.hoisted(() => ({
value: null as null | { combo: { getKeySequences: () => string[] } }
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync:
@@ -21,12 +25,13 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybindingByCommandId: () => null
getKeybindingByCommandId: () => keybindingMock.value
})
}))
describe('commandStore', () => {
beforeEach(() => {
keybindingMock.value = null
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -164,6 +169,16 @@ describe('commandStore', () => {
expect(store.getCommand('tip.fn')?.tooltip).toBe('Dynamic tip')
})
it('resolves icon as function', () => {
const store = useCommandStore()
store.registerCommand({
id: 'icon.fn',
function: vi.fn(),
icon: () => 'pi pi-bolt'
})
expect(store.getCommand('icon.fn')?.icon).toBe('pi pi-bolt')
})
it('uses explicit menubarLabel over label', () => {
const store = useCommandStore()
store.registerCommand({
@@ -184,6 +199,16 @@ describe('commandStore', () => {
})
expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label')
})
it('resolves menubarLabel as function', () => {
const store = useCommandStore()
store.registerCommand({
id: 'mbl.fn',
function: vi.fn(),
menubarLabel: () => 'Dynamic menu'
})
expect(store.getCommand('mbl.fn')?.menubarLabel).toBe('Dynamic menu')
})
})
describe('formatKeySequence', () => {
@@ -193,5 +218,17 @@ describe('commandStore', () => {
const cmd = store.getCommand('no.kb')!
expect(store.formatKeySequence(cmd)).toBe('')
})
it('formats keybinding sequences', () => {
const store = useCommandStore()
keybindingMock.value = {
combo: { getKeySequences: () => ['Control+A', 'Shift+B'] }
}
store.registerCommand({ id: 'with.kb', function: vi.fn() })
const cmd = store.getCommand('with.kb')!
expect(store.formatKeySequence(cmd)).toBe('Ctrl+A + Shift+B')
})
})
})

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
@@ -141,6 +141,114 @@ describe('dialogStore', () => {
})
describe('basic dialog operations', () => {
it('generates a key when none is provided', () => {
const store = useDialogStore()
const dialog = store.showDialog({ component: MockComponent })
expect(dialog.key).toMatch(/^dialog-/)
expect(store.isDialogOpen(dialog.key)).toBe(true)
})
it('evicts the first stack entry when the stack is full', () => {
const store = useDialogStore()
for (let i = 0; i < 11; i++) {
store.showDialog({
key: `dialog-${i}`,
component: MockComponent,
priority: i
})
}
expect(store.dialogStack).toHaveLength(10)
expect(store.isDialogOpen('dialog-9')).toBe(false)
})
it('stores optional header and footer components and props', () => {
const store = useDialogStore()
const dialog = store.showDialog({
key: 'with-slots',
component: MockComponent,
headerComponent: MockComponent,
footerComponent: MockComponent,
headerProps: { title: 'Header' },
footerProps: { action: 'Save' }
})
expect(dialog.headerComponent).toBeDefined()
expect(dialog.footerComponent).toBeDefined()
expect(dialog.headerProps).toEqual({ title: 'Header' })
expect(dialog.footerProps).toEqual({ action: 'Save' })
})
it('runs dialog lifecycle handlers', () => {
const store = useDialogStore()
const onClose = vi.fn()
const dialog = store.showDialog({
key: 'lifecycle',
component: MockComponent,
dialogComponentProps: { onClose }
})
// A second dialog steals focus so the mousedown below actually
// exercises riseDialog's promote-to-front behavior.
store.showDialog({ key: 'other', component: MockComponent })
const props =
dialog.dialogComponentProps as typeof dialog.dialogComponentProps & {
onAfterHide: () => void
onMaximize: () => void
onUnmaximize: () => void
pt: { root: { onMousedown: () => void } }
}
props.onMaximize()
expect(dialog.dialogComponentProps.maximized).toBe(true)
props.onUnmaximize()
expect(dialog.dialogComponentProps.maximized).toBe(false)
expect(store.activeKey).toBe('other')
props.pt.root.onMousedown()
expect(store.activeKey).toBe('lifecycle')
props.onAfterHide()
expect(onClose).toHaveBeenCalledOnce()
expect(store.isDialogOpen('lifecycle')).toBe(false)
})
it('does nothing when rising or closing a missing dialog', () => {
const store = useDialogStore()
store.riseDialog({ key: 'missing' })
store.closeDialog({ key: 'missing' })
expect(store.dialogStack).toEqual([])
expect(store.activeKey).toBeNull()
})
it('closes the active dialog when no key is provided', () => {
const store = useDialogStore()
store.showDialog({ key: 'active', component: MockComponent })
store.closeDialog()
expect(store.isDialogOpen('active')).toBe(false)
expect(store.activeKey).toBeNull()
})
it('disables escape closing for a non-closable active dialog', () => {
const store = useDialogStore()
const dialog = store.showDialog({
key: 'locked',
component: MockComponent,
dialogComponentProps: { closable: false }
})
expect(dialog.dialogComponentProps.closeOnEscape).toBe(false)
})
it('should show and close dialogs', () => {
const store = useDialogStore()
@@ -208,6 +316,86 @@ describe('dialogStore', () => {
false
)
})
it('updates only content props when dialog component props are omitted', () => {
const store = useDialogStore()
store.showDialog({
key: 'content-only',
component: MockContentPropsComponent,
props: { openingAction: null }
})
expect(
store.updateDialog({
key: 'content-only',
contentProps: { openingAction: 'open' }
})
).toBe(true)
expect(store.dialogStack[0].contentProps.openingAction).toBe('open')
})
it('updates only dialog component props when content props are omitted', () => {
const store = useDialogStore()
store.showDialog({
key: 'dialog-props-only',
component: MockContentPropsComponent,
dialogComponentProps: { dismissableMask: true }
})
expect(
store.updateDialog({
key: 'dialog-props-only',
dialogComponentProps: { dismissableMask: false }
})
).toBe(true)
expect(store.dialogStack[0].dialogComponentProps.dismissableMask).toBe(
false
)
})
it('returns false when updating a missing dialog', () => {
const store = useDialogStore()
expect(
store.updateDialog({
key: 'missing',
contentProps: { openingAction: 'open' }
})
).toBe(false)
})
it('creates and reuses extension dialogs with extension-prefixed keys', () => {
const store = useDialogStore()
const first = store.showExtensionDialog({
key: 'external',
component: MockComponent
})
const second = store.showExtensionDialog({
key: 'extension-external',
component: MockComponent
})
expect(first?.key).toBe('extension-external')
expect(second?.key).toBe(first?.key)
expect(store.dialogStack).toHaveLength(1)
})
it('rejects extension dialogs without keys', () => {
const store = useDialogStore()
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const dialog = store.showExtensionDialog({
key: '',
component: MockComponent
})
expect(dialog).toBeUndefined()
expect(error).toHaveBeenCalledWith('Extension dialog key is required')
error.mockRestore()
})
})
describe('ESC key behavior with multiple dialogs', () => {

View File

@@ -112,6 +112,36 @@ describe('domWidgetStore', () => {
store.activateWidget('non-existent')
}).not.toThrow()
})
it('should ignore deactivating non-existent widgets', () => {
store.deactivateWidget('non-existent')
expect(store.widgetStates.size).toBe(0)
})
it('should replace registered widgets', () => {
const widget = createMockDOMWidget('widget-1')
const replacement = {
...createMockDOMWidget('widget-1'),
value: 'replacement'
}
store.registerWidget(widget)
store.deactivateWidget('widget-1')
store.setWidget(replacement)
const state = store.widgetStates.get('widget-1')
expect(state?.widget.value).toBe('replacement')
expect(state?.active).toBe(true)
})
it('should ignore missing widgets when replacing', () => {
const widget = createMockDOMWidget('widget-1')
store.setWidget(widget)
expect(store.widgetStates.size).toBe(0)
})
})
describe('computed states', () => {

View File

@@ -0,0 +1,149 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
const canvasStoreMock = vi.hoisted(() => ({ linearMode: false }))
vi.mock('@/constants/coreMenuCommands', () => ({
CORE_MENU_COMMANDS: [[['Core'], ['core.command']]]
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync:
(fn: () => Promise<void>, errorHandler?: (e: unknown) => void) =>
async () => {
try {
await fn()
} catch (e) {
if (errorHandler) errorHandler(e)
else throw e
}
}
})
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybindingByCommandId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasStoreMock
}))
describe('menuItemStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
canvasStoreMock.linearMode = false
})
it('records that linear mode has been seen', () => {
canvasStoreMock.linearMode = true
const store = useMenuItemStore()
expect(store.hasSeenLinear).toBe(true)
})
it('creates nested groups, separators, and active-state metadata', () => {
const store = useMenuItemStore()
const activeItem: MenuItem = {
label: 'Active',
comfyCommand: { id: 'active', function: vi.fn(), active: () => true }
}
const plainItem: MenuItem = { label: 'Plain' }
store.registerMenuGroup(['File', 'Export'], [activeItem])
store.registerMenuGroup(['File', 'Export'], [plainItem])
const file = store.menuItems[0]
const exportGroup = file.items?.[0]
expect(file.label).toBe('File')
expect(exportGroup?.items).toEqual([
activeItem,
{ separator: true },
plainItem
])
expect(store.menuItemHasActiveStateChildren['File.Export']).toBe(true)
})
it('repairs existing group items before appending children', () => {
const store = useMenuItemStore()
store.menuItems.push({ label: 'Tools' })
store.registerMenuGroup(['Tools'], [{ label: 'Child' }])
expect(store.menuItems[0].items).toEqual([{ label: 'Child' }])
})
it('maps command ids to executable menu items', async () => {
const commandStore = useCommandStore()
const fn = vi.fn()
commandStore.registerCommand({
id: 'test.command',
function: fn,
icon: 'icon-[lucide--test]',
label: 'Label',
menubarLabel: 'Menu Label',
tooltip: 'Tip'
})
const store = useMenuItemStore()
const item = store.commandIdToMenuItem('test.command', ['Tools'])
await item.command?.({ originalEvent: new Event('click'), item })
expect(fn).toHaveBeenCalled()
expect(item).toMatchObject({
label: 'Menu Label',
icon: 'icon-[lucide--test]',
tooltip: 'Tip',
parentPath: 'Tools'
})
})
it('loads extension menu commands only for commands owned by the extension', () => {
const commandStore = useCommandStore()
commandStore.registerCommand({
id: 'owned',
function: vi.fn(),
menubarLabel: 'Owned'
})
const store = useMenuItemStore()
store.loadExtensionMenuCommands({
name: 'extension',
commands: [{ id: 'owned', function: vi.fn() }],
menuCommands: [{ path: ['Tools'], commands: ['owned', 'external'] }]
})
store.loadExtensionMenuCommands({ name: 'plain' })
store.loadExtensionMenuCommands({
name: 'empty',
menuCommands: [{ path: ['Tools'], commands: ['missing'] }]
})
expect(store.menuItems[0].items?.map((item) => item.label)).toEqual([
'Owned'
])
})
it('registers core menu commands', () => {
const commandStore = useCommandStore()
commandStore.registerCommand({
id: 'core.command',
function: vi.fn(),
menubarLabel: 'Core Command'
})
const store = useMenuItemStore()
store.registerCoreMenuCommands()
expect(store.menuItems[0].items?.[0].label).toBe('Core Command')
})
})

View File

@@ -90,6 +90,12 @@ describe('templateRankingStore', () => {
})
describe('computePopularScore', () => {
it('normalizes usage against itself before a largest score is loaded', () => {
const store = useTemplateRankingStore()
expect(store.computePopularScore('2024-01-01', 10)).toBeGreaterThan(0.8)
})
it('does not use searchRank', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100

View File

@@ -0,0 +1,25 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useExtensionStore } from '@/stores/extensionStore'
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
describe('topbarBadgeStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('collects topbar badges from registered extensions', () => {
const extensionStore = useExtensionStore()
extensionStore.registerExtension({
name: 'badges',
topbarBadges: [{ text: 'Beta', label: 'BETA' }]
})
extensionStore.registerExtension({ name: 'plain' })
const store = useTopbarBadgeStore()
expect(store.badges).toEqual([{ text: 'Beta', label: 'BETA' }])
})
})

View File

@@ -33,8 +33,6 @@ 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,7 +9,6 @@ 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'
@@ -165,7 +164,6 @@ 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"