test: add E2E coverage for credit balance and top-up conversions

Adds a credits.spec.ts that builds on the subscription/credits fixture
infrastructure to exercise the credit-related helpers in
src/base/credits/comfyCredits.ts via the actual UI surfaces:

- Popover credit balance rendering (formatCreditsFromCents -> centsToCredits
  -> formatNumber) at $5.00 and $0.50 balances
- Top-up dialog default state, $10/$100 preset clicks, and bidirectional
  stepper increments (usdToCredits / creditsToUsd)

Also adds Vitest cases for the previously uncovered formatNumber guard
(line 25, clamping minimumFractionDigits) and clampUsd, which had zero
consumers in app code.

A handful of data-testid attributes were added to the affected components
to provide stable, role-agnostic selectors for the new E2E spec.
This commit is contained in:
Glary-Bot
2026-05-05 08:07:11 +00:00
parent 6949044d65
commit fde30c2b4f
7 changed files with 291 additions and 8 deletions

View File

@@ -211,6 +211,13 @@ export const TestIds = {
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',
clearHistoryAction: 'clear-history-action',

View File

@@ -0,0 +1,237 @@
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)
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')
}
)
}
)

View File

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

View File

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

View File

@@ -39,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"

View File

@@ -66,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"

View File

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