Files
ComfyUI_frontend/src/components/common/UrlInput.test.ts
Alexander Brown 3de387429a test: migrate 11 interactive component tests from VTU to VTL (Phase 2) (#10490)
## Summary

Phase 2 of the VTL migration: migrate 11 interactive component tests
from @vue/test-utils to @testing-library/vue (69 tests).

Stacked on #10471.

## Changes

- **What**: Migrate BatchCountEdit, BypassButton, BuilderFooterToolbar,
ComfyActionbar, SidebarIcon, EditableText, UrlInput, SearchInput,
TagsInput, TreeExplorerTreeNode, ColorCustomizationSelector from VTU to
VTL
- **Pattern transforms**: `trigger('click')` → `userEvent.click()`,
`setValue()` → `userEvent.type()`, `findComponent().props()` →
`getByRole/getByText/getByTestId`, `emitted()` → callback props
- **Removed**: 4 `@ts-expect-error` annotations, 1 change-detector test
(SearchInput `vm.focus`)
- **PrimeVue**: `data-pc-name` selectors + `aria-pressed` for
SelectButton, container queries for ColorPicker/InputIcon

## Review Focus

- PrimeVue escape hatches in ColorCustomizationSelector
(SelectButton/ColorPicker lack standard ARIA roles)
- Teleport test in ComfyActionbar uses `container.querySelector`
intentionally (scoped to teleport target)
- SearchInput debounce tests use `fireEvent.update` instead of
`userEvent.type` due to fake timer interaction
- EditableText escape-then-blur test simplified:
`userEvent.keyboard('{Escape}')` already triggers blur internally

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10490-test-migrate-11-interactive-component-tests-from-VTU-to-VTL-Phase-2-32e6d73d3650817ca40fd61395499e3f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-26 19:46:31 -07:00

224 lines
7.0 KiB
TypeScript

import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import UrlInput from './UrlInput.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('UrlInput', () => {
function renderComponent(
props: ComponentProps<typeof UrlInput> & {
placeholder?: string
disabled?: boolean
'onUpdate:modelValue'?: (value: string) => void
}
) {
const user = userEvent.setup()
const result = render(UrlInput, {
global: {
plugins: [PrimeVue],
components: { IconField, InputIcon, InputText }
},
props
})
return { ...result, user }
}
it('passes through additional attributes to input element', () => {
renderComponent({
modelValue: '',
placeholder: 'Enter URL',
disabled: true
})
expect(screen.getByRole('textbox')).toBeDisabled()
})
it('emits update:modelValue on blur', async () => {
const onUpdate = vi.fn()
const { user } = renderComponent({
modelValue: '',
placeholder: 'Enter URL',
'onUpdate:modelValue': onUpdate
})
const input = screen.getByRole('textbox')
await user.type(input, 'https://test.com/')
expect(onUpdate).not.toHaveBeenCalled()
await user.tab()
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledWith('https://test.com/')
})
it('renders spinner when validation is loading', async () => {
const { container, rerender } = renderComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () =>
new Promise(() => {
// Never resolves, simulating perpetual loading state
})
})
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-spinner class with no ARIA role
expect(container.querySelector('.pi-spinner')).not.toBeNull()
})
it('renders check icon when validation is valid', async () => {
const { container, rerender } = renderComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () => Promise.resolve(true)
})
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role
expect(container.querySelector('.pi-check')).not.toBeNull()
})
it('renders cross icon when validation is invalid', async () => {
const { container, rerender } = renderComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () => Promise.resolve(false)
})
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-times class with no ARIA role
expect(container.querySelector('.pi-times')).not.toBeNull()
})
it('validates on mount', async () => {
const { container } = renderComponent({
modelValue: 'https://test.com',
validateUrlFn: () => Promise.resolve(true)
})
await nextTick()
await nextTick()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role
expect(container.querySelector('.pi-check')).not.toBeNull()
})
it('triggers validation when clicking the validation icon', async () => {
let validationCount = 0
const { container, user } = renderComponent({
modelValue: 'https://test.com',
validateUrlFn: () => {
validationCount++
return Promise.resolve(true)
}
})
// Wait for initial validation
await nextTick()
await nextTick()
// Click the validation icon
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role
const icon = container.querySelector('.pi-check')!
await user.click(icon)
await nextTick()
await nextTick()
expect(validationCount).toBe(2) // Once on mount, once on click
})
it('prevents multiple simultaneous validations', async () => {
let validationCount = 0
const { container, rerender, user } = renderComponent({
modelValue: '',
validateUrlFn: () => {
validationCount++
return new Promise(() => {
// Never resolves, simulating perpetual loading state
})
}
})
await rerender({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
// Trigger multiple validations in quick succession
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon
const spinner = container.querySelector('.pi-spinner')!
await user.click(spinner)
await user.click(spinner)
await user.click(spinner)
await nextTick()
await nextTick()
expect(validationCount).toBe(1) // Only the initial validation should occur
})
describe('input cleaning functionality', () => {
it('trims whitespace when user types', async () => {
renderComponent({
modelValue: '',
placeholder: 'Enter URL'
})
const input = screen.getByRole('textbox')
// The component strips whitespace on input via handleInput
// We use fireEvent.input to simulate the input event handler directly
await fireEvent.update(input, ' https://leading-space.com')
await nextTick()
expect(input).toHaveValue('https://leading-space.com')
await fireEvent.update(input, 'https://trailing-space.com ')
await nextTick()
expect(input).toHaveValue('https://trailing-space.com')
await fireEvent.update(input, ' https://both-spaces.com ')
await nextTick()
expect(input).toHaveValue('https://both-spaces.com')
await fireEvent.update(input, 'https:// middle-space.com')
await nextTick()
expect(input).toHaveValue('https://middle-space.com')
})
it('trims whitespace when value set externally', async () => {
const { rerender } = renderComponent({
modelValue: ' https://initial-value.com ',
placeholder: 'Enter URL'
})
const input = screen.getByRole('textbox')
// Check initial value is trimmed
expect(input).toHaveValue('https://initial-value.com')
// Update props with whitespace
await rerender({ modelValue: ' https://updated-value.com ' })
await nextTick()
// Check updated value is trimmed
expect(input).toHaveValue('https://updated-value.com')
})
})
})