Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions browser_tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,12 @@ WebKit engine does not expose embedded-WKWebView globals such as
context `userAgent`. See `browser_tests/tests/cloudLoginIosWebview.spec.ts` for the
reference pattern.

`@auth` is a behavioural tag rather than a routing one: it makes the
`comfyPage` fixture seed the mocked Firebase user (`cloudAuth.mockAuth()`),
which `@cloud` also does implicitly. Reach for it when a signed-in user is
needed outside the cloud project — pair `['@oss', '@auth']` to exercise a
logged-in account on the localhost/desktop bundle, where `isCloud` is false.

Organizational tags are used for manual `--grep` filtering (not project
routing). Common ones in the suite: `@smoke`, `@slow`, `@screenshot`, `@canvas`,
`@node`, `@widget`, `@vue-nodes`, `@subgraph`, `@ui`. Apply them so a test is
Expand Down
2 changes: 1 addition & 1 deletion browser_tests/fixtures/ComfyPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ export const comfyPageFixture = base.extend<{
console.error(e)
}

if (testInfo.tags.includes('@cloud')) {
if (testInfo.tags.includes('@cloud') || testInfo.tags.includes('@auth')) {
await comfyPage.cloudAuth.mockAuth()
}

Expand Down
99 changes: 99 additions & 0 deletions browser_tests/tests/localCreditsGating.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { expect } from '@playwright/test'

import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog'
import {
createBalance,
createSubscriptionStatus
} from '@e2e/fixtures/data/subscriptionFixtures'
import { TestIds } from '@e2e/fixtures/selectors'

/**
* Local (non-Cloud) credits gating.
*
* Every other billing/credits spec is tagged `@cloud`, so the suite only ever
* runs against the `DISTRIBUTION=cloud` bundle. This one runs in the `chromium`
* project (localhost bundle, `isCloud === false`) as a signed-in desktop user
* with no Comfy Cloud subscription.
*
* Nothing fetches subscription status on a local boot until Settings ▸ Credits
* mounts the credits tile. That first fetch flips `isFreeTier` to true, which
* used to swap in an "Upgrade to add credits" CTA and reroute every later
* top-up into `showSubscriptionRequiredDialog`. Both are cloud-only, so off
* cloud they no-op silently and the purchase flow dies until app restart.
*/
const freeTierStatus = createSubscriptionStatus({
is_active: true,
subscription_id: 'sub_local_free',
subscription_tier: 'FREE',
subscription_duration: 'MONTHLY',
renewal_date: '2099-01-01T00:00:00Z',
end_date: null,
has_fund: true
})

const balance = createBalance({
amount_micros: 5000,
effective_balance_micros: 5000,
prepaid_balance_micros: 5000,
currency: 'usd'
})

const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/customers/cloud-subscription-status', (route) =>
route.fulfill({ json: freeTierStatus })
)
await page.route('**/customers/balance', (route) =>
route.fulfill({ json: balance })
)
await use(page)
}
})

test.describe(
'Credits on a local build without a Cloud subscription',
{ tag: ['@oss', '@auth'] },
() => {
test('never offers a subscribe/upgrade path and keeps top-up working after Settings ▸ Credits', async ({
comfyPage
}) => {
const page = comfyPage.page
const topUpDialog = new TopUpCreditsDialog(page)
const userButton = page.getByTestId(TestIds.user.currentUserButton)

await expect(userButton).toBeVisible()
await comfyPage.toast.closeToasts()

await userButton.click()
await expect(
page.getByTestId(TestIds.user.currentUserPopover)
).toBeVisible()
await expect(
page.getByTestId('upgrade-to-add-credits-button')
).toBeHidden()
await expect(page.getByTestId('plans-pricing-menu-item')).toBeHidden()

await page.getByTestId('add-credits-button').click()
await topUpDialog.waitForVisible()
await topUpDialog.close()

await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Credits').click()
await expect(
comfyPage.settingDialog.contentArea.getByText('Total credits')
).toBeVisible()
await expect(
page.getByTestId('upgrade-to-add-credits-button')
).toBeHidden()
await expect(
comfyPage.settingDialog.contentArea.getByText('Upgrade to add credits')
).toBeHidden()
await comfyPage.settingDialog.close()

await userButton.click()
await page.getByTestId('add-credits-button').click()
await topUpDialog.waitForVisible()
})
}
)
19 changes: 19 additions & 0 deletions src/platform/cloud/subscription/components/CreditsTile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Subscription = Pick<SubscriptionInfo, 'duration' | 'renewalDate'> & {
type TeamStop = CurrentTeamCreditStop

const state = vi.hoisted(() => ({
isCloud: true,
balance: null as Balance | null,
subscription: null as Subscription | null,
isActiveSubscription: false,
Expand Down Expand Up @@ -49,6 +50,12 @@ vi.mock('@/composables/useErrorHandling', () => ({
})
}))

vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return state.isCloud
}
}))

vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
balance: computed(() => state.balance),
Expand Down Expand Up @@ -159,6 +166,7 @@ function activeProSubscription() {

describe('CreditsTile', () => {
beforeEach(() => {
state.isCloud = true
state.balance = null
state.subscription = null
state.isActiveSubscription = false
Expand Down Expand Up @@ -376,6 +384,17 @@ describe('CreditsTile', () => {
expect(state.showPricingTable).toHaveBeenCalledOnce()
})

it('keeps add-credits on the free tier off cloud, where there is no upgrade flow', async () => {
activeProSubscription()
state.isCloud = false
state.isFreeTier = true
renderTile()
expect(screen.queryByText('Upgrade to add credits')).toBeNull()
await userEvent.click(screen.getByText('Add credits'))
expect(state.showPricingTable).not.toHaveBeenCalled()
expect(state.showTopUpCreditsDialog).toHaveBeenCalledOnce()
})

it('hides the action button when the user lacks the top-up permission', () => {
activeProSubscription()
state.canTopUp = false
Expand Down
5 changes: 4 additions & 1 deletion src/platform/cloud/subscription/components/CreditsTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,11 @@

<div v-if="showActionButton" class="flex flex-col gap-3">
<Button
v-if="isFreeTier"
v-if="isCloud && isFreeTier"
variant="subscribe"
size="lg"
class="w-full font-normal"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
Expand All @@ -207,6 +208,7 @@
'bg-interface-menu-component-surface-selected text-text-primary'
)
"
data-testid="add-credits-button"
@click="handleAddCredits"
>
{{ $t('subscription.addCredits') }}
Expand Down Expand Up @@ -234,6 +236,7 @@ import {
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import enMessages from '@/locales/en/main.json'
import CurrentUserPopoverWorkspace from './CurrentUserPopoverWorkspace.vue'

const state = vi.hoisted(() => ({
isCloud: true,
isActiveSubscription: true,
isFreeTier: false,
isCancelled: false,
Expand All @@ -32,6 +33,12 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))

vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return state.isCloud
}
}))

vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => state.isActiveSubscription),
Expand Down Expand Up @@ -156,6 +163,7 @@ function renderComponent(

describe('CurrentUserPopoverWorkspace', () => {
beforeEach(() => {
state.isCloud = true
state.isActiveSubscription = true
state.isFreeTier = false
state.isCancelled = false
Expand Down Expand Up @@ -251,4 +259,33 @@ describe('CurrentUserPopoverWorkspace', () => {

expect(state.showPricingTable).toHaveBeenCalledOnce()
})

it('drops every subscribe reference off cloud and leaves top-up intact', () => {
state.isCloud = false
state.isFreeTier = true
state.canTopUp = true
state.canManageSubscription = true
renderComponent('personal', 'owner')

expect(
screen.queryByTestId('upgrade-to-add-credits-button')
).not.toBeInTheDocument()
expect(
screen.queryByTestId('plans-pricing-menu-item')
).not.toBeInTheDocument()
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
})

it('hides the resubscribe prompt off cloud', () => {
state.isCloud = false
state.isCancelled = true
state.canTopUp = true
state.canManageSubscription = true
state.canManageSubscriptionLifecycle = true
renderComponent('team', 'owner')

expect(
screen.queryByRole('button', { name: 'Resubscribe' })
).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@
</Button>
<!-- Upgrade to add credits (free tier) -->
<Button
v-if="isActiveSubscription && permissions.canTopUp && isFreeTier"
v-if="
isCloud && isActiveSubscription && permissions.canTopUp && isFreeTier
"
variant="subscribe"
size="sm"
data-testid="upgrade-to-add-credits-button"
Expand Down Expand Up @@ -294,15 +296,16 @@ const displayedCredits = computed(() => {
})

const showPlansAndPricing = computed(
() => permissions.value.canManageSubscription
() => isCloud && permissions.value.canManageSubscription
)
const showManagePlan = computed(
() => permissions.value.canManageSubscription && isActiveSubscription.value
)
const showSubscribeAction = computed(
() =>
(isCancelled.value && permissions.value.canManageSubscriptionLifecycle) ||
(!isActiveSubscription.value && permissions.value.canManageSubscription)
isCloud &&
((isCancelled.value && permissions.value.canManageSubscriptionLifecycle) ||
(!isActiveSubscription.value && permissions.value.canManageSubscription))
)

const handleOpenUserSettings = () => {
Expand Down
18 changes: 17 additions & 1 deletion src/services/dialogService.topUpCredits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
const closeDialog = vi.hoisted(() => vi.fn())
const state = vi.hoisted(() => ({
isCloud: true,
isActiveSubscription: true,
isFreeTier: false,
type: 'workspace' as 'workspace' | 'legacy',
Expand All @@ -27,7 +28,9 @@ vi.mock('@/platform/telemetry', () => ({
}))

vi.mock('@/platform/distribution/types', () => ({
isCloud: true
get isCloud() {
return state.isCloud
}
}))

vi.mock('@/composables/billing/useBillingContext', () => ({
Expand Down Expand Up @@ -62,6 +65,7 @@ import { useDialogService } from '@/services/dialogService'
describe('showTopUpCreditsDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
state.isCloud = true
state.isActiveSubscription = true
state.isFreeTier = false
state.type = 'workspace'
Expand Down Expand Up @@ -120,4 +124,16 @@ describe('showTopUpCreditsDialog', () => {
})
expect(showDialog).not.toHaveBeenCalled()
})

it('opens the purchase dialog for a free-tier user off cloud, where the subscribe flow does not exist', async () => {
state.isCloud = false
state.isFreeTier = true
state.type = 'legacy'

await useDialogService().showTopUpCreditsDialog()

expect(showSubscriptionDialog).not.toHaveBeenCalled()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('top-up-credits')
})
})
2 changes: 1 addition & 1 deletion src/services/dialogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ export const useDialogService = () => {
isInsufficientCredits?: boolean
}) {
const { isActiveSubscription, isFreeTier, type } = useBillingContext()
if (!isActiveSubscription.value || isFreeTier.value) {
if (isCloud && (!isActiveSubscription.value || isFreeTier.value)) {
await showSubscriptionRequiredDialog({
reason: options?.isInsufficientCredits
? 'out_of_credits'
Expand Down
Loading