Compare commits

...

4 Commits

Author SHA1 Message Date
Christian Byrne
8df5ac979e test: extract expectedCreditsInput helper to dedup credits→pay conversion 2026-05-03 21:52:11 -07:00
Glary-Bot
fe69964ef5 test: add credits→pay reverse sync test and harden max-boundary assertion
Add credits-driven sync test covering the creditsModel setter reverse
path. Harden max-boundary test with blur + credits clamp assertion to
catch pay/credits desync bugs.
2026-05-03 01:32:19 -07:00
Glary-Bot
81f604edf8 fix: type mock responses with satisfies and extract expectedCredits helper
Address CodeRabbit review: type route.fulfill() payloads using
CloudSubscriptionStatusResponse and GetCustomerBalanceResponse with
satisfies, and replace hardcoded credit values with expectedCredits()
helper using imported CREDITS_PER_USD constant.
2026-05-03 01:32:19 -07:00
Glary-Bot
d04450dac4 test: add FormattedNumberStepper coverage and TopUpCredits dialog E2E tests
Add comprehensive Vitest component tests (24 cases) covering all 12
previously untested functions in FormattedNumberStepper.vue through
behavioral user interactions. Add E2E integration smoke test for the
TopUpCredits dialog with page object extending BaseDialog.
2026-05-03 01:31:40 -07:00
3 changed files with 503 additions and 1 deletions

View File

@@ -1,7 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
import type { WorkspaceStore } from '@e2e/types/globals'
export class TopUpCreditsDialog extends BaseDialog {
readonly heading: Locator
@@ -13,8 +13,21 @@ export class TopUpCreditsDialog extends BaseDialog {
readonly payAmountInput: Locator
readonly pricingLink: Locator
readonly payInput: Locator
readonly creditsInput: Locator
readonly decrementPay: Locator
readonly incrementPay: Locator
readonly decrementCredits: Locator
readonly incrementCredits: Locator
readonly presetButtons: Locator
readonly buyButton: Locator
override readonly closeButton: Locator
readonly minWarning: Locator
readonly ceilingWarning: Locator
constructor(page: Page) {
super(page)
this.heading = this.root.getByRole('heading', { name: 'Add more credits' })
this.insufficientHeading = this.root.getByRole('heading', {
name: 'Add more credits to run'
@@ -41,6 +54,29 @@ export class TopUpCreditsDialog extends BaseDialog {
this.pricingLink = this.root.getByRole('link', {
name: 'View pricing details'
})
const steppers = this.root.locator('label')
const payStepper = steppers.first()
const creditsStepper = steppers.nth(1)
this.payInput = payStepper.locator('input[inputmode="numeric"]')
this.creditsInput = creditsStepper.locator('input[inputmode="numeric"]')
this.decrementPay = payStepper.getByRole('button', { name: 'Decrement' })
this.incrementPay = payStepper.getByRole('button', { name: 'Increment' })
this.decrementCredits = creditsStepper.getByRole('button', {
name: 'Decrement'
})
this.incrementCredits = creditsStepper.getByRole('button', {
name: 'Increment'
})
this.presetButtons = this.root.getByRole('button', { name: /^\$\d+$/ })
this.buyButton = this.root.getByRole('button', {
name: /continue to payment|add credits/i
})
// Headless dialog uses its own X button, not PrimeVue's header close
this.closeButton = this.root.locator('button:has([class*="lucide--x"])')
this.minWarning = this.root.getByText(/minimum/i)
this.ceilingWarning = this.root.getByText(/maximum/i)
}
async open(options?: { isInsufficientCredits?: boolean }) {
@@ -51,4 +87,8 @@ export class TopUpCreditsDialog extends BaseDialog {
}, options)
await this.waitForVisible()
}
getPresetButton(amount: number): Locator {
return this.root.getByRole('button', { name: `$${amount}`, exact: true })
}
}

View File

@@ -1,8 +1,26 @@
import type { operations } from '@comfyorg/registry-types'
import { expect } from '@playwright/test'
import { CREDITS_PER_USD } from '@/base/credits/comfyCredits'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog'
type GetCustomerBalanceResponse =
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
// Step: $5 when pay < $100, $50 when pay < $1000, $100 otherwise
// MIN_AMOUNT = $5, MAX_AMOUNT = $10,000
function expectedCredits(usd: number): string {
return (usd * CREDITS_PER_USD).toLocaleString('en-US')
}
function expectedCreditsInput(usd: number): string {
return expectedCredits(usd).replaceAll(',', '')
}
test.describe('TopUpCredits dialog', { tag: '@ui' }, () => {
let dialog: TopUpCreditsDialog
@@ -56,3 +74,113 @@ test.describe('TopUpCredits dialog', { tag: '@ui' }, () => {
await expect(dialog.pricingLink).toHaveAttribute('target', '_blank')
})
})
test.describe('Top Up Credits Dialog', { tag: '@ui' }, () => {
let dialog: TopUpCreditsDialog
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.route(
'**/cloud-subscription-status**',
async (route) => {
await route.fulfill({
json: {
is_active: true,
subscription_tier: 'PRO'
} satisfies CloudSubscriptionStatusResponse
})
}
)
await comfyPage.page.route('**/customers/balance**', async (route) => {
await route.fulfill({
json: {
amount_micros: 1_000_000,
currency: 'usd'
} satisfies GetCustomerBalanceResponse
})
})
dialog = new TopUpCreditsDialog(comfyPage.page)
await dialog.open()
})
test('preset buttons update both steppers', async () => {
await dialog.getPresetButton(25).click()
await expect(dialog.payInput).toHaveValue('25')
await expect(dialog.creditsInput).toHaveValue(expectedCredits(25))
})
test('increment pay stepper updates credits', async () => {
await dialog.getPresetButton(25).click()
await dialog.incrementPay.click()
await expect(dialog.payInput).toHaveValue('30')
await expect(dialog.creditsInput).toHaveValue(expectedCredits(30))
})
test('decrement pay stepper updates credits', async () => {
await dialog.getPresetButton(50).click()
await dialog.decrementPay.click()
await expect(dialog.payInput).toHaveValue('45')
await expect(dialog.creditsInput).toHaveValue(expectedCredits(45))
})
test('typing in pay stepper updates credits', async () => {
await dialog.payInput.fill('')
await dialog.payInput.pressSequentially('500')
await dialog.payInput.blur()
await expect(dialog.creditsInput).toHaveValue(expectedCredits(500))
})
test('typing in credits stepper updates pay', async () => {
const credits = expectedCreditsInput(50)
await dialog.creditsInput.fill('')
await dialog.creditsInput.pressSequentially(credits)
await dialog.creditsInput.blur()
await expect(dialog.payInput).toHaveValue('50')
})
test('max ceiling warning appears when exceeding max', async () => {
await dialog.payInput.fill('')
await dialog.payInput.pressSequentially('99999')
await dialog.payInput.blur()
await expect(dialog.ceilingWarning).toBeVisible()
await expect(dialog.payInput).toHaveValue('10,000')
await expect(dialog.creditsInput).toHaveValue(expectedCredits(10_000))
})
test('min amount warning appears for values below minimum', async () => {
await dialog.payInput.fill('')
await dialog.payInput.pressSequentially('2')
await dialog.payInput.blur()
await expect(dialog.minWarning).toBeVisible()
})
test('buy button disabled for sub-minimum amount', async () => {
await dialog.payInput.fill('')
await dialog.payInput.pressSequentially('3')
await dialog.payInput.blur()
await expect(dialog.buyButton).toBeDisabled()
await expect(dialog.minWarning).toBeVisible()
})
test('buy button disabled when amount is zero', async () => {
await dialog.payInput.fill('')
await dialog.payInput.pressSequentially('0')
await dialog.payInput.blur()
await expect(dialog.buyButton).toBeDisabled()
})
test('dialog closes via close button', async () => {
await dialog.closeButton.click()
await expect(dialog.root).toBeHidden()
})
})

View File

@@ -0,0 +1,334 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
decrement: 'Decrement',
increment: 'Increment'
}
}
}
})
async function flush() {
await nextTick()
await nextTick()
}
function renderStepper(
props?: Partial<{
modelValue: number
min: number
max: number
step: number | ((value: number) => number)
formatOptions: Intl.NumberFormatOptions
disabled: boolean
'onUpdate:modelValue': (value: number) => void
onMaxReached: () => void
}>
) {
const user = userEvent.setup()
const result = render(FormattedNumberStepper, {
props: { modelValue: 0, ...props },
global: { plugins: [i18n] }
})
return { user, ...result }
}
describe('FormattedNumberStepper', () => {
describe('rendering', () => {
it('renders increment and decrement buttons', () => {
renderStepper()
expect(
screen.getByRole('button', { name: 'Decrement' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Increment' })
).toBeInTheDocument()
})
it('renders formatted initial value', () => {
renderStepper({ modelValue: 1000 })
expect(screen.getByRole('textbox')).toHaveValue('1,000')
})
it('renders prefix slot content', () => {
render(FormattedNumberStepper, {
props: { modelValue: 0 },
slots: { prefix: '<span data-testid="prefix">$</span>' },
global: { plugins: [i18n] }
})
expect(screen.getByTestId('prefix')).toBeInTheDocument()
})
it('renders suffix slot content', () => {
render(FormattedNumberStepper, {
props: { modelValue: 0 },
slots: { suffix: '<span data-testid="suffix">USD</span>' },
global: { plugins: [i18n] }
})
expect(screen.getByTestId('suffix')).toBeInTheDocument()
})
it('applies disabled state to input and buttons', () => {
renderStepper({ disabled: true })
expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('button', { name: 'Decrement' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Increment' })).toBeDisabled()
})
})
describe('handleStep', () => {
it('clicking + increments by step amount', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 1,
step: 2,
'onUpdate:modelValue': onUpdate
})
await user.click(screen.getByRole('button', { name: 'Increment' }))
expect(onUpdate).toHaveBeenCalledWith(3)
expect(screen.getByRole('textbox')).toHaveValue('3')
})
it('clicking decrements by step amount', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 10,
min: 0,
step: 2,
'onUpdate:modelValue': onUpdate
})
await user.click(screen.getByRole('button', { name: 'Decrement' }))
expect(onUpdate).toHaveBeenCalledWith(8)
expect(screen.getByRole('textbox')).toHaveValue('8')
})
it('disables button at min', () => {
renderStepper({ modelValue: 0, min: 0 })
expect(screen.getByRole('button', { name: 'Decrement' })).toBeDisabled()
})
it('disables + button at max', () => {
renderStepper({ modelValue: 10, max: 10 })
expect(screen.getByRole('button', { name: 'Increment' })).toBeDisabled()
})
it('clamps value to [min, max] range', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 9,
min: 0,
max: 10,
step: 5,
'onUpdate:modelValue': onUpdate
})
await user.click(screen.getByRole('button', { name: 'Increment' }))
expect(onUpdate).toHaveBeenCalledWith(10)
expect(screen.getByRole('textbox')).toHaveValue('10')
})
it('calls function-based step prop with current value', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const stepFn = vi.fn((value: number) => value + 1)
const { user } = renderStepper({
modelValue: 4,
step: stepFn,
'onUpdate:modelValue': onUpdate
})
await user.click(screen.getByRole('button', { name: 'Increment' }))
expect(stepFn).toHaveBeenCalledWith(4)
expect(onUpdate).toHaveBeenCalledWith(9)
})
})
describe('handleInputChange', () => {
it('typing digits updates modelValue', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 0,
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '1234')
expect(onUpdate).toHaveBeenLastCalledWith(1234)
})
it('strips non-numeric characters', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 0,
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'a1b2c3')
expect(onUpdate).toHaveBeenLastCalledWith(123)
expect(input).toHaveValue('123')
})
it('formats input with grouping separators', async () => {
const { user } = renderStepper({ modelValue: 0 })
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '1000')
expect(input).toHaveValue('1,000')
})
it('emits max-reached when input exceeds max', async () => {
const onMaxReached = vi.fn()
const { user } = renderStepper({
modelValue: 0,
max: 500,
onMaxReached
})
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '999')
expect(onMaxReached).toHaveBeenCalled()
})
it('clamps to max when input exceeds max', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 0,
max: 500,
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '999')
expect(onUpdate).toHaveBeenLastCalledWith(500)
expect(input).toHaveValue('500')
})
it('resolves empty input to 0', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 123,
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.clear(input)
expect(onUpdate).toHaveBeenLastCalledWith(0)
expect(input).toHaveValue('0')
})
})
describe('handleInputBlur', () => {
it('clamps value below min to min on blur', async () => {
const onUpdate = vi.fn<(value: number) => void>()
const { user } = renderStepper({
modelValue: 5,
min: 10,
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.click(input)
await user.tab()
expect(onUpdate).toHaveBeenLastCalledWith(10)
expect(input).toHaveValue('10')
})
it('reformats display on blur', async () => {
const { user } = renderStepper({ modelValue: 1000 })
const input = screen.getByRole('textbox')
await user.click(input)
await user.tab()
expect(input).toHaveValue('1,000')
})
})
describe('handleInputFocus', () => {
it('moves cursor to end of input on focus', async () => {
const { user } = renderStepper({ modelValue: 1000 })
const input = screen.getByRole('textbox') as HTMLInputElement
input.setSelectionRange(0, 0)
await user.click(input)
await flush()
expect(input.selectionStart).toBe(input.value.length)
expect(input.selectionEnd).toBe(input.value.length)
})
})
describe('v-model reactivity', () => {
it('external modelValue change updates displayed text', async () => {
const { rerender } = renderStepper({ modelValue: 1000 })
expect(screen.getByRole('textbox')).toHaveValue('1,000')
await rerender({ modelValue: 2500 })
await flush()
expect(screen.getByRole('textbox')).toHaveValue('2,500')
})
it('external modelValue change does not overwrite while focused', async () => {
const { user, rerender } = renderStepper({ modelValue: 1000 })
const input = screen.getByRole('textbox')
await user.click(input)
await user.clear(input)
await user.type(input, '2')
await rerender({ modelValue: 9000 })
await flush()
expect(input).toHaveValue('2')
})
})
describe('formatNumber', () => {
it('respects formatOptions prop', () => {
renderStepper({
modelValue: 1000,
formatOptions: {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}
})
expect(screen.getByRole('textbox')).toHaveValue('$1,000')
})
it('applies locale formatting with default options', () => {
renderStepper({ modelValue: 1234567 })
expect(screen.getByRole('textbox')).toHaveValue('1,234,567')
})
})
})