mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
22 Commits
glary/node
...
glary/cred
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b4612c67b | ||
|
|
fde30c2b4f | ||
|
|
6949044d65 | ||
|
|
828ba48ba6 | ||
|
|
b2a445ed73 | ||
|
|
19cc62a79c | ||
|
|
a879e63c77 | ||
|
|
009ea1c054 | ||
|
|
5355773e53 | ||
|
|
c2ddda0eb7 | ||
|
|
a47a8949ac | ||
|
|
ee88893b1b | ||
|
|
747bbf2c2c | ||
|
|
262b74954f | ||
|
|
618277384e | ||
|
|
dc634279a1 | ||
|
|
e1334b1955 | ||
|
|
d0e9a5867f | ||
|
|
6c72a9d711 | ||
|
|
f87fc5ec9a | ||
|
|
8360110933 | ||
|
|
afed5e98e8 |
90
browser_tests/fixtures/data/subscriptionFixtures.ts
Normal file
90
browser_tests/fixtures/data/subscriptionFixtures.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { operations } from '@comfyorg/registry-types'
|
||||
|
||||
export type SubscriptionStatusResponse =
|
||||
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
|
||||
|
||||
export type BalanceResponse =
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
|
||||
export function createSubscriptionStatus(
|
||||
overrides: Partial<SubscriptionStatusResponse> = {}
|
||||
): SubscriptionStatusResponse {
|
||||
return {
|
||||
is_active: false,
|
||||
subscription_id: null,
|
||||
subscription_tier: 'FREE',
|
||||
subscription_duration: null,
|
||||
has_fund: false,
|
||||
renewal_date: null,
|
||||
end_date: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function createBalance(
|
||||
overrides: Partial<BalanceResponse> = {}
|
||||
): BalanceResponse {
|
||||
return {
|
||||
amount_micros: 0,
|
||||
prepaid_balance_micros: 0,
|
||||
cloud_credit_balance_micros: 0,
|
||||
pending_charges_micros: 0,
|
||||
effective_balance_micros: 0,
|
||||
currency: 'USD',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export const UNSUBSCRIBED: SubscriptionStatusResponse =
|
||||
createSubscriptionStatus({
|
||||
is_active: false,
|
||||
subscription_id: null,
|
||||
subscription_tier: 'FREE',
|
||||
end_date: null
|
||||
})
|
||||
|
||||
export const FREE_TIER_ACTIVE: SubscriptionStatusResponse =
|
||||
createSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_free_001',
|
||||
subscription_tier: 'FREE',
|
||||
renewal_date: '2099-12-31T00:00:00.000Z'
|
||||
})
|
||||
|
||||
export const CREATOR_ACTIVE: SubscriptionStatusResponse =
|
||||
createSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_creator_001',
|
||||
subscription_tier: 'CREATOR',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-12-31T00:00:00.000Z'
|
||||
})
|
||||
|
||||
export const PRO_ACTIVE: SubscriptionStatusResponse = createSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_pro_001',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-12-31T00:00:00.000Z'
|
||||
})
|
||||
|
||||
export const CANCELLED_SUBSCRIPTION: SubscriptionStatusResponse =
|
||||
createSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_cancelled_001',
|
||||
subscription_tier: 'CREATOR',
|
||||
subscription_duration: 'MONTHLY',
|
||||
end_date: '2099-12-31T00:00:00.000Z'
|
||||
})
|
||||
|
||||
export const ZERO_BALANCE: BalanceResponse = createBalance({
|
||||
amount_micros: 0,
|
||||
effective_balance_micros: 0
|
||||
})
|
||||
|
||||
export const FUNDED_BALANCE: BalanceResponse = createBalance({
|
||||
amount_micros: 2_500_000,
|
||||
prepaid_balance_micros: 1_000_000,
|
||||
cloud_credit_balance_micros: 1_500_000,
|
||||
effective_balance_micros: 2_500_000
|
||||
})
|
||||
246
browser_tests/fixtures/helpers/SubscriptionHelper.ts
Normal file
246
browser_tests/fixtures/helpers/SubscriptionHelper.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import {
|
||||
createBalance,
|
||||
createSubscriptionStatus,
|
||||
UNSUBSCRIBED,
|
||||
ZERO_BALANCE
|
||||
} from '@e2e/fixtures/data/subscriptionFixtures'
|
||||
import type {
|
||||
BalanceResponse,
|
||||
SubscriptionStatusResponse
|
||||
} from '@e2e/fixtures/data/subscriptionFixtures'
|
||||
|
||||
export interface SubscriptionConfig {
|
||||
status: SubscriptionStatusResponse
|
||||
balance: BalanceResponse
|
||||
}
|
||||
|
||||
function emptyConfig(): SubscriptionConfig {
|
||||
return {
|
||||
status: createSubscriptionStatus(UNSUBSCRIBED),
|
||||
balance: createBalance(ZERO_BALANCE)
|
||||
}
|
||||
}
|
||||
|
||||
export type SubscriptionOperator = (
|
||||
config: SubscriptionConfig
|
||||
) => SubscriptionConfig
|
||||
|
||||
export function withSubscriptionStatus(
|
||||
overrides: Partial<SubscriptionStatusResponse>
|
||||
): SubscriptionOperator {
|
||||
return (config) => ({
|
||||
...config,
|
||||
status: { ...config.status, ...overrides }
|
||||
})
|
||||
}
|
||||
|
||||
export function withBalance(
|
||||
overrides: Partial<BalanceResponse>
|
||||
): SubscriptionOperator {
|
||||
return (config) => ({
|
||||
...config,
|
||||
balance: { ...config.balance, ...overrides }
|
||||
})
|
||||
}
|
||||
|
||||
export function withActiveSubscription(
|
||||
tier: NonNullable<SubscriptionStatusResponse['subscription_tier']> = 'CREATOR'
|
||||
): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_tier: tier,
|
||||
renewal_date: '2099-12-31T00:00:00.000Z',
|
||||
end_date: null
|
||||
})
|
||||
}
|
||||
|
||||
export function withFreeTier(): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'FREE',
|
||||
end_date: null
|
||||
})
|
||||
}
|
||||
|
||||
export function withCancelledSubscription(): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'CREATOR',
|
||||
end_date: '2099-12-31T00:00:00.000Z'
|
||||
})
|
||||
}
|
||||
|
||||
export function withUnsubscribed(): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: false,
|
||||
subscription_tier: 'FREE',
|
||||
end_date: null,
|
||||
renewal_date: null
|
||||
})
|
||||
}
|
||||
|
||||
export function withCredits(amountMicros: number): SubscriptionOperator {
|
||||
return withBalance({
|
||||
amount_micros: amountMicros,
|
||||
effective_balance_micros: amountMicros
|
||||
})
|
||||
}
|
||||
|
||||
export class SubscriptionHelper {
|
||||
private statusResponse: SubscriptionStatusResponse
|
||||
private balanceResponse: BalanceResponse
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
config: SubscriptionConfig = emptyConfig()
|
||||
) {
|
||||
this.statusResponse = createSubscriptionStatus(config.status)
|
||||
this.balanceResponse = createBalance(config.balance)
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.page.addInitScript(() => {
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
subscription_required: true
|
||||
}
|
||||
})
|
||||
|
||||
// The cloud build calls `/api/features` at boot via `refreshRemoteConfig`,
|
||||
// which overwrites `window.__CONFIG__` wholesale. Mock it to preserve
|
||||
// `subscription_required: true` after that fetch resolves.
|
||||
const featuresPattern = '**/api/features'
|
||||
const featuresHandler = async (route: Route) => {
|
||||
await route.fulfill({ json: { subscription_required: true } })
|
||||
}
|
||||
this.routeHandlers.push({
|
||||
pattern: featuresPattern,
|
||||
handler: featuresHandler
|
||||
})
|
||||
await this.page.route(featuresPattern, featuresHandler)
|
||||
|
||||
const statusPattern = '**/customers/cloud-subscription-status'
|
||||
const statusHandler = async (route: Route) => {
|
||||
await route.fulfill({ json: this.statusResponse })
|
||||
}
|
||||
this.routeHandlers.push({ pattern: statusPattern, handler: statusHandler })
|
||||
await this.page.route(statusPattern, statusHandler)
|
||||
|
||||
const balancePattern = '**/customers/balance'
|
||||
const balanceHandler = async (route: Route) => {
|
||||
await route.fulfill({ json: this.balanceResponse })
|
||||
}
|
||||
this.routeHandlers.push({
|
||||
pattern: balancePattern,
|
||||
handler: balanceHandler
|
||||
})
|
||||
await this.page.route(balancePattern, balanceHandler)
|
||||
|
||||
const checkoutPattern = '**/customers/cloud-subscription-checkout**'
|
||||
const checkoutHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
json: { checkout_url: 'https://checkout.stripe.com/mock' }
|
||||
})
|
||||
}
|
||||
this.routeHandlers.push({
|
||||
pattern: checkoutPattern,
|
||||
handler: checkoutHandler
|
||||
})
|
||||
await this.page.route(checkoutPattern, checkoutHandler)
|
||||
}
|
||||
|
||||
configure(...operators: SubscriptionOperator[]): void {
|
||||
const base: SubscriptionConfig = {
|
||||
status: createSubscriptionStatus(this.statusResponse),
|
||||
balance: createBalance(this.balanceResponse)
|
||||
}
|
||||
const config = operators.reduce<SubscriptionConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
base
|
||||
)
|
||||
this.statusResponse = createSubscriptionStatus(config.status)
|
||||
this.balanceResponse = createBalance(config.balance)
|
||||
}
|
||||
|
||||
setStatus(overrides: Partial<SubscriptionStatusResponse>): void {
|
||||
this.statusResponse = {
|
||||
...this.statusResponse,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
setBalance(overrides: Partial<BalanceResponse>): void {
|
||||
this.balanceResponse = {
|
||||
...this.balanceResponse,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed localStorage with a pending checkout attempt.
|
||||
* Required for `visibilitychange` to trigger a subscription re-fetch,
|
||||
* because `recoverPendingSubscriptionCheckout` checks
|
||||
* `hasPendingSubscriptionCheckoutAttempt()` before fetching.
|
||||
*
|
||||
* Call AFTER page navigation (localStorage needs a page context).
|
||||
*/
|
||||
async seedPendingCheckout(
|
||||
tier: string = 'standard',
|
||||
cycle: string = 'monthly'
|
||||
): Promise<void> {
|
||||
const storageKey = PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
await this.page.evaluate(
|
||||
([key, t, c]) => {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
attempt_id: `test-${Date.now()}`,
|
||||
started_at_ms: Date.now(),
|
||||
tier: t,
|
||||
cycle: c,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
},
|
||||
[storageKey, tier, cycle] as const
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch `visibilitychange` to trigger pending-checkout recovery.
|
||||
* The app listens for this event and re-fetches subscription status
|
||||
* when a pending checkout attempt exists in localStorage.
|
||||
*/
|
||||
async triggerSubscriptionRefetch(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.statusResponse = createSubscriptionStatus(UNSUBSCRIBED)
|
||||
this.balanceResponse = createBalance(ZERO_BALANCE)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSubscriptionHelper(
|
||||
page: Page,
|
||||
...operators: SubscriptionOperator[]
|
||||
): SubscriptionHelper {
|
||||
const config = operators.reduce<SubscriptionConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
return new SubscriptionHelper(page, config)
|
||||
}
|
||||
@@ -87,6 +87,7 @@ export const TestIds = {
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button',
|
||||
subscribeButton: 'topbar-subscribe-button',
|
||||
subscribeToRunButton: 'subscribe-to-run-button',
|
||||
loginButton: 'login-button',
|
||||
loginButtonPopover: 'login-button-popover',
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
@@ -206,7 +207,16 @@ export const TestIds = {
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
},
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
currentUserButton: 'current-user-button',
|
||||
currentUserIndicator: 'current-user-indicator',
|
||||
currentUserPopover: 'current-user-popover'
|
||||
},
|
||||
credits: {
|
||||
popoverCreditBalance: 'popover-credit-balance',
|
||||
addCreditsButton: 'add-credits-button',
|
||||
upgradeToAddCreditsButton: 'upgrade-to-add-credits-button',
|
||||
topUpPayAmount: 'top-up-pay-amount',
|
||||
topUpGetAmount: 'top-up-get-amount'
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
|
||||
241
browser_tests/tests/credits.spec.ts
Normal file
241
browser_tests/tests/credits.spec.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
createSubscriptionHelper,
|
||||
withActiveSubscription,
|
||||
withCredits
|
||||
} from '@e2e/fixtures/helpers/SubscriptionHelper'
|
||||
import type { SubscriptionHelper } from '@e2e/fixtures/helpers/SubscriptionHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* The `*_balance_micros` fields on the mocked balance response are consumed by
|
||||
* the UI through `formatCreditsFromCents`, which treats the value as cents
|
||||
* (despite the field name). Keep this helper colocated with the assertions so
|
||||
* the unit translation is explicit.
|
||||
*/
|
||||
const usdToBalanceUnits = (usd: number): number => Math.round(usd * 100)
|
||||
|
||||
async function openUserPopover(page: Page): Promise<Locator> {
|
||||
// Use dispatchEvent to bypass the subscription-required dialog backdrop that
|
||||
// can transiently overlay the topbar during page boot, mirroring the
|
||||
// canonical pattern used in subscription.spec.ts.
|
||||
await page.getByTestId(TestIds.user.currentUserButton).dispatchEvent('click')
|
||||
const popover = page.getByTestId(TestIds.user.currentUserPopover)
|
||||
await expect(popover).toBeVisible()
|
||||
return popover
|
||||
}
|
||||
|
||||
async function dismissSubscriptionDialogIfOpen(page: Page): Promise<void> {
|
||||
const dialog = page.locator('[aria-labelledby="subscription-required"]')
|
||||
const appeared = await expect(dialog)
|
||||
.toBeVisible({ timeout: 2000 })
|
||||
.then(() => true)
|
||||
.catch((e: Error) => {
|
||||
if (e.message.includes('Timeout')) return false
|
||||
throw e
|
||||
})
|
||||
if (!appeared) return
|
||||
const closeButton = dialog.getByRole('button', { name: /close/i }).first()
|
||||
if (await closeButton.isVisible()) {
|
||||
await closeButton.click()
|
||||
} else {
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
function createCreditsTest(
|
||||
...defaultOps: Parameters<typeof createSubscriptionHelper>[1][]
|
||||
) {
|
||||
return comfyPageFixture.extend<{
|
||||
subscriptionHelper: SubscriptionHelper
|
||||
}>({
|
||||
subscriptionHelper: [
|
||||
async ({ comfyPage }, use) => {
|
||||
const helper = createSubscriptionHelper(comfyPage.page, ...defaultOps)
|
||||
await helper.mock()
|
||||
// Disable the cloud-subscription extension so its watcher does not
|
||||
// auto-open the subscription dialog on app boot — the PrimeVue
|
||||
// Dialog mask would otherwise intercept pointer events on the topbar.
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.Extension.Disabled': ['Comfy.Cloud.Subscription']
|
||||
})
|
||||
await comfyPage.page.reload()
|
||||
// Wait for Firebase auth so the topbar user button is rendered before
|
||||
// the test body runs.
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.user.currentUserButton)
|
||||
).toBeVisible()
|
||||
await dismissSubscriptionDialogIfOpen(comfyPage.page)
|
||||
// The subscription bootstrap can surface a transient error toast
|
||||
// top-right that overlaps the popover region and intercepts clicks on
|
||||
// `add-credits-button`. Clear any queued toasts before yielding.
|
||||
await comfyPage.toast.closeToasts()
|
||||
await use(helper)
|
||||
await helper.clearMocks()
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const FIVE_USD_OPS = [
|
||||
withActiveSubscription('CREATOR'),
|
||||
withCredits(usdToBalanceUnits(5))
|
||||
]
|
||||
|
||||
const FIFTY_CENT_OPS = [
|
||||
withActiveSubscription('CREATOR'),
|
||||
withCredits(usdToBalanceUnits(0.5))
|
||||
]
|
||||
|
||||
const fiveDollarTest = createCreditsTest(...FIVE_USD_OPS)
|
||||
const fiftyCentTest = createCreditsTest(...FIFTY_CENT_OPS)
|
||||
|
||||
fiveDollarTest.describe(
|
||||
'Credits — popover balance ($5)',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
fiveDollarTest(
|
||||
'shows formatted credits derived from the balance response',
|
||||
async ({ comfyPage }) => {
|
||||
const popover = await openUserPopover(comfyPage.page)
|
||||
const balance = popover.getByTestId(
|
||||
TestIds.credits.popoverCreditBalance
|
||||
)
|
||||
await expect(balance).toHaveText('1,055')
|
||||
}
|
||||
)
|
||||
|
||||
fiveDollarTest(
|
||||
'add credits button is visible for active paid subscribers',
|
||||
async ({ comfyPage }) => {
|
||||
const popover = await openUserPopover(comfyPage.page)
|
||||
await expect(
|
||||
popover.getByTestId(TestIds.credits.addCreditsButton)
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
fiftyCentTest.describe(
|
||||
'Credits — popover balance ($0.50)',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
fiftyCentTest(
|
||||
'renders sub-dollar balances as whole credits',
|
||||
async ({ comfyPage }) => {
|
||||
const popover = await openUserPopover(comfyPage.page)
|
||||
const balance = popover.getByTestId(
|
||||
TestIds.credits.popoverCreditBalance
|
||||
)
|
||||
await expect(balance).toHaveText('106')
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
fiveDollarTest.describe(
|
||||
'Credits — top-up dialog conversions',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
async function openTopUpDialog(page: Page): Promise<void> {
|
||||
const popover = await openUserPopover(page)
|
||||
await popover.getByTestId(TestIds.credits.addCreditsButton).click()
|
||||
await expect(
|
||||
page.getByTestId(TestIds.credits.topUpPayAmount)
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
fiveDollarTest(
|
||||
'default $50 selection renders 10,550 credits in You Get',
|
||||
async ({ comfyPage }) => {
|
||||
await openTopUpDialog(comfyPage.page)
|
||||
const getInput = comfyPage.page
|
||||
.getByTestId(TestIds.credits.topUpGetAmount)
|
||||
.getByRole('textbox')
|
||||
await expect(getInput).toHaveValue('10,550')
|
||||
}
|
||||
)
|
||||
|
||||
fiveDollarTest(
|
||||
'$10 preset updates You Get to usdToCredits(10) = 2,110',
|
||||
async ({ comfyPage }) => {
|
||||
await openTopUpDialog(comfyPage.page)
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: '$10', exact: true })
|
||||
.click()
|
||||
|
||||
const getInput = comfyPage.page
|
||||
.getByTestId(TestIds.credits.topUpGetAmount)
|
||||
.getByRole('textbox')
|
||||
const payInput = comfyPage.page
|
||||
.getByTestId(TestIds.credits.topUpPayAmount)
|
||||
.getByRole('textbox')
|
||||
|
||||
await expect(getInput).toHaveValue('2,110')
|
||||
await expect(payInput).toHaveValue('10')
|
||||
}
|
||||
)
|
||||
|
||||
fiveDollarTest(
|
||||
'$100 preset updates You Get to usdToCredits(100) = 21,100',
|
||||
async ({ comfyPage }) => {
|
||||
await openTopUpDialog(comfyPage.page)
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: '$100', exact: true })
|
||||
.click()
|
||||
|
||||
const getInput = comfyPage.page
|
||||
.getByTestId(TestIds.credits.topUpGetAmount)
|
||||
.getByRole('textbox')
|
||||
const payInput = comfyPage.page
|
||||
.getByTestId(TestIds.credits.topUpPayAmount)
|
||||
.getByRole('textbox')
|
||||
|
||||
await expect(getInput).toHaveValue('21,100')
|
||||
await expect(payInput).toHaveValue('100')
|
||||
}
|
||||
)
|
||||
|
||||
fiveDollarTest(
|
||||
'incrementing You Pay updates You Get via usdToCredits',
|
||||
async ({ comfyPage }) => {
|
||||
await openTopUpDialog(comfyPage.page)
|
||||
const payContainer = comfyPage.page.getByTestId(
|
||||
TestIds.credits.topUpPayAmount
|
||||
)
|
||||
await payContainer.getByRole('button', { name: /increment/i }).click()
|
||||
|
||||
const getInput = comfyPage.page
|
||||
.getByTestId(TestIds.credits.topUpGetAmount)
|
||||
.getByRole('textbox')
|
||||
const payInput = payContainer.getByRole('textbox')
|
||||
await expect(payInput).toHaveValue('55')
|
||||
await expect(getInput).toHaveValue('11,605')
|
||||
}
|
||||
)
|
||||
|
||||
fiveDollarTest(
|
||||
'incrementing You Get updates You Pay via creditsToUsd',
|
||||
async ({ comfyPage }) => {
|
||||
await openTopUpDialog(comfyPage.page)
|
||||
const getContainer = comfyPage.page.getByTestId(
|
||||
TestIds.credits.topUpGetAmount
|
||||
)
|
||||
await getContainer.getByRole('button', { name: /increment/i }).click()
|
||||
|
||||
const payInput = comfyPage.page
|
||||
.getByTestId(TestIds.credits.topUpPayAmount)
|
||||
.getByRole('textbox')
|
||||
const getInput = getContainer.getByRole('textbox')
|
||||
await expect(getInput).toHaveValue('11,605')
|
||||
await expect(payInput).toHaveValue('55')
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
262
browser_tests/tests/subscription.spec.ts
Normal file
262
browser_tests/tests/subscription.spec.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
createSubscriptionHelper,
|
||||
withActiveSubscription,
|
||||
withFreeTier,
|
||||
withUnsubscribed
|
||||
} from '@e2e/fixtures/helpers/SubscriptionHelper'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import type { SubscriptionHelper } from '@e2e/fixtures/helpers/SubscriptionHelper'
|
||||
|
||||
async function openUserPopover(page: Page): Promise<Locator> {
|
||||
// Use dispatchEvent instead of click() to bypass Playwright's actionability
|
||||
// check — in the cloud environment a subscription dialog backdrop can be
|
||||
// present during initial page load and would block a standard click.
|
||||
await page.getByTestId(TestIds.user.currentUserButton).dispatchEvent('click')
|
||||
const popover = page.getByTestId(TestIds.user.currentUserPopover)
|
||||
await expect(popover).toBeVisible()
|
||||
return popover
|
||||
}
|
||||
|
||||
async function clickPopoverSubscribe(page: Page): Promise<void> {
|
||||
const popover = await openUserPopover(page)
|
||||
// Use dispatchEvent instead of click() because the click opens the
|
||||
// subscription dialog whose backdrop appears mid-action; Playwright's
|
||||
// actionability re-check would otherwise see the mask intercepting and
|
||||
// retry until timeout. The button is already known-visible from
|
||||
// openUserPopover, so dispatching a synthetic click is safe here.
|
||||
await popover
|
||||
.getByRole('button', { name: /subscribe/i })
|
||||
.first()
|
||||
.dispatchEvent('click')
|
||||
}
|
||||
|
||||
// Closes the auto-opened subscription-required dialog if present.
|
||||
// Polls briefly because the dialog opens asynchronously after the
|
||||
// `isLoggedIn` watcher fires on app boot.
|
||||
async function dismissSubscriptionDialogIfOpen(page: Page): Promise<void> {
|
||||
// Target only the subscription-required dialog by its known aria-labelledby
|
||||
// key — avoids strict-mode violations when multiple GlobalDialog items are
|
||||
// on the stack simultaneously.
|
||||
const dialog = page.locator('[aria-labelledby="subscription-required"]')
|
||||
// Use expect with a short timeout: this is intentionally a "dismiss if open"
|
||||
// helper, so absence of the dialog (TimeoutError) is not a failure — we
|
||||
// discard only the timeout error, not any other unexpected exception.
|
||||
const appeared = await expect(dialog)
|
||||
.toBeVisible({ timeout: 2000 })
|
||||
.then(() => true)
|
||||
.catch((e: Error) => {
|
||||
if (e.message.includes('Timeout')) return false
|
||||
throw e
|
||||
})
|
||||
if (!appeared) return
|
||||
const closeButton = dialog.getByRole('button', { name: /close/i }).first()
|
||||
if (await closeButton.isVisible()) {
|
||||
await closeButton.click()
|
||||
} else {
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
// Installs subscription mocks AFTER comfyPage.setup() and reloads the page
|
||||
// so `addInitScript` (which sets `window.__CONFIG__.subscription_required`)
|
||||
// applies before module-level reads in `ComfyRunButton/index.ts` evaluate.
|
||||
// Depending on `comfyPage` here forces ordering: comfyPage's auth + setup
|
||||
// runs first, then mocks are installed, then the page reloads with the
|
||||
// mocked config + intercepted endpoints in place.
|
||||
function createSubscriptionTest(
|
||||
...defaultOps: Parameters<typeof createSubscriptionHelper>[1][]
|
||||
) {
|
||||
return comfyPageFixture.extend<{
|
||||
subscriptionHelper: SubscriptionHelper
|
||||
}>({
|
||||
subscriptionHelper: [
|
||||
async ({ comfyPage }, use) => {
|
||||
const helper = createSubscriptionHelper(comfyPage.page, ...defaultOps)
|
||||
await helper.mock()
|
||||
// Disable the cloud-subscription extension so its `requireActive
|
||||
// Subscription` watcher doesn't auto-open the subscription dialog
|
||||
// on app boot. The PrimeVue Dialog mask would otherwise intercept
|
||||
// pointer events on every topbar button these tests interact with.
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.Extension.Disabled': ['Comfy.Cloud.Subscription']
|
||||
})
|
||||
await comfyPage.page.reload()
|
||||
// Wait for Firebase auth to resolve: the user button is v-if="isLoggedIn"
|
||||
// and only renders after onAuthStateChanged fires with the mock user from
|
||||
// IndexedDB. waitForAppReady() does not wait for this — Firebase resolves
|
||||
// asynchronously after app boot. Waiting here ensures the button is
|
||||
// present before any test body tries to click it.
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.user.currentUserButton)
|
||||
).toBeVisible()
|
||||
// Defense-in-depth: if the dialog still surfaces (e.g. via a
|
||||
// different code path), dismiss it before the test runs.
|
||||
await dismissSubscriptionDialogIfOpen(comfyPage.page)
|
||||
await use(helper)
|
||||
await helper.clearMocks()
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribedTest = createSubscriptionTest(withUnsubscribed())
|
||||
const subscribedTest = createSubscriptionTest(withActiveSubscription('CREATOR'))
|
||||
const freeTierTest = createSubscriptionTest(withFreeTier())
|
||||
|
||||
unsubscribedTest.describe(
|
||||
'Subscription buttons — unsubscribed',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
unsubscribedTest(
|
||||
'SubscribeToRun visible when unsubscribed',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'SubscribeToRun click opens subscription dialog',
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
.click()
|
||||
await expect(
|
||||
comfyPage.page.locator('[aria-labelledby="subscription-required"]')
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'SubscribeToRun shows short label at narrow viewport',
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page.setViewportSize({ width: 393, height: 851 })
|
||||
const btn = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.subscribeToRunButton
|
||||
)
|
||||
await expect(btn).toBeVisible()
|
||||
await expect(btn).not.toContainText(/to run/i)
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'User popover shows subscribe when unsubscribed',
|
||||
async ({ comfyPage }) => {
|
||||
const popover = await openUserPopover(comfyPage.page)
|
||||
await expect(
|
||||
popover.getByRole('button', { name: /subscribe/i })
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'User popover subscribe click opens dialog',
|
||||
async ({ comfyPage }) => {
|
||||
await clickPopoverSubscribe(comfyPage.page)
|
||||
await expect(
|
||||
comfyPage.page.locator('[aria-labelledby="subscription-required"]')
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'Subscription state transition updates UI after re-fetch',
|
||||
async ({ comfyPage, subscriptionHelper }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeVisible()
|
||||
|
||||
// Simulate returning from Stripe checkout: seed pending checkout,
|
||||
// mutate mock to return active subscription, trigger re-fetch.
|
||||
await subscriptionHelper.seedPendingCheckout('standard', 'monthly')
|
||||
subscriptionHelper.setStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'MONTHLY'
|
||||
})
|
||||
await subscriptionHelper.triggerSubscriptionRefetch()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'Cleanup prevents stale subscription state after dialog close',
|
||||
async ({ comfyPage, subscriptionHelper }) => {
|
||||
await clickPopoverSubscribe(comfyPage.page)
|
||||
// Use the aria-labelledby key to target only the subscription dialog —
|
||||
// avoids strict-mode violations when a second GlobalDialog is stacked.
|
||||
const dialog = comfyPage.page.locator(
|
||||
'[aria-labelledby="subscription-required"]'
|
||||
)
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click()
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
await subscriptionHelper.seedPendingCheckout('standard', 'monthly')
|
||||
subscriptionHelper.setStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'MONTHLY'
|
||||
})
|
||||
await subscriptionHelper.triggerSubscriptionRefetch()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
subscribedTest.describe(
|
||||
'Subscription buttons — subscribed',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
subscribedTest(
|
||||
'SubscribeToRun hidden when subscribed',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.queueButton)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
subscribedTest(
|
||||
'Topbar subscribe button hidden for paid tier',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.queueButton)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeButton)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
freeTierTest.describe(
|
||||
'Subscription buttons — free tier',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
freeTierTest(
|
||||
'Topbar subscribe button visible for free tier',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeButton)
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CREDITS_PER_USD,
|
||||
COMFY_CREDIT_RATE_CENTS,
|
||||
centsToCredits,
|
||||
clampUsd,
|
||||
creditsToCents,
|
||||
creditsToUsd,
|
||||
formatCredits,
|
||||
@@ -43,4 +44,36 @@ describe('comfyCredits helpers', () => {
|
||||
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
|
||||
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
|
||||
})
|
||||
|
||||
test('clamps minimumFractionDigits when caller lowers maximumFractionDigits below the default minimum', () => {
|
||||
expect(
|
||||
formatCredits({
|
||||
value: 1.5,
|
||||
locale: 'en-US',
|
||||
numberOptions: { maximumFractionDigits: 0 }
|
||||
})
|
||||
).toBe('2')
|
||||
})
|
||||
|
||||
describe('clampUsd', () => {
|
||||
test('returns 0 for NaN', () => {
|
||||
expect(clampUsd(Number.NaN)).toBe(0)
|
||||
})
|
||||
|
||||
test('clamps values below the minimum up to 1', () => {
|
||||
expect(clampUsd(0)).toBe(1)
|
||||
expect(clampUsd(-50)).toBe(1)
|
||||
})
|
||||
|
||||
test('clamps values above the maximum down to 1000', () => {
|
||||
expect(clampUsd(1000.01)).toBe(1000)
|
||||
expect(clampUsd(99999)).toBe(1000)
|
||||
})
|
||||
|
||||
test('passes values within the allowed range through unchanged', () => {
|
||||
expect(clampUsd(1)).toBe(1)
|
||||
expect(clampUsd(50)).toBe(50)
|
||||
expect(clampUsd(1000)).toBe(1000)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
|
||||
<!-- You Get -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="flex flex-1 flex-col gap-3" data-testid="top-up-get-amount">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youGet') }}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
class="p-1 hover:bg-transparent"
|
||||
variant="muted-textonly"
|
||||
:aria-label="$t('g.currentUser')"
|
||||
data-testid="current-user-button"
|
||||
@click="popover?.toggle($event)"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
data-testid="current-user-popover"
|
||||
class="current-user-popover -m-3 w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
@@ -38,9 +39,12 @@
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
<span
|
||||
v-else
|
||||
data-testid="popover-credit-balance"
|
||||
class="text-base font-semibold text-base-foreground"
|
||||
>{{ formattedBalance }}</span
|
||||
>
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
data-testid="current-user-popover"
|
||||
class="current-user-popover -m-3 w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
@@ -65,9 +66,12 @@
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
displayedCredits
|
||||
}}</span>
|
||||
<span
|
||||
v-else
|
||||
data-testid="popover-credit-balance"
|
||||
class="text-base font-semibold text-base-foreground"
|
||||
>{{ displayedCredits }}</span
|
||||
>
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
|
||||
<!-- You Get -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="flex flex-1 flex-col gap-3" data-testid="top-up-get-amount">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youGet') }}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user