mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-03 12:42:01 +00:00
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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, watch } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -39,8 +40,8 @@ describe('SearchInput', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function mountComponent(props = {}) {
|
||||
return mount(SearchInput, {
|
||||
function renderComponent(props = {}) {
|
||||
const result = render(SearchInput, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
@@ -63,140 +64,142 @@ describe('SearchInput', () => {
|
||||
...props
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
describe('debounced search', () => {
|
||||
it('should debounce search input by 300ms', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
const onSearch = vi.fn()
|
||||
renderComponent({ onSearch })
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await input.setValue('test')
|
||||
await fireEvent.update(input, 'test')
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
expect(onSearch).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
expect(onSearch).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toEqual([['test']])
|
||||
expect(onSearch).toHaveBeenCalledWith('test')
|
||||
})
|
||||
|
||||
it('should reset debounce timer on each keystroke', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
const onSearch = vi.fn()
|
||||
renderComponent({ onSearch })
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await input.setValue('t')
|
||||
await fireEvent.update(input, 't')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
await input.setValue('te')
|
||||
await fireEvent.update(input, 'te')
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
|
||||
await input.setValue('tes')
|
||||
await fireEvent.update(input, 'tes')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
expect(onSearch).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeTruthy()
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['tes'])
|
||||
expect(onSearch).toHaveBeenCalled()
|
||||
expect(onSearch).toHaveBeenCalledWith('tes')
|
||||
})
|
||||
|
||||
it('should only emit final value after rapid typing', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
const onSearch = vi.fn()
|
||||
renderComponent({ onSearch })
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
|
||||
for (const term of searchTerms) {
|
||||
await input.setValue(term)
|
||||
await fireEvent.update(input, term)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toBeFalsy()
|
||||
expect(onSearch).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('search')).toHaveLength(1)
|
||||
expect(wrapper.emitted('search')?.[0]).toEqual(['search'])
|
||||
expect(onSearch).toHaveBeenCalledTimes(1)
|
||||
expect(onSearch).toHaveBeenCalledWith('search')
|
||||
})
|
||||
})
|
||||
|
||||
describe('model sync', () => {
|
||||
it('should sync external model changes to internal state', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'initial' })
|
||||
const input = wrapper.find('input')
|
||||
const { rerender } = renderComponent({ modelValue: 'initial' })
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
expect(input.element.value).toBe('initial')
|
||||
expect(input).toHaveValue('initial')
|
||||
|
||||
await wrapper.setProps({ modelValue: 'external update' })
|
||||
await rerender({ modelValue: 'external update' })
|
||||
await nextTick()
|
||||
|
||||
expect(input.element.value).toBe('external update')
|
||||
expect(input).toHaveValue('external update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder', () => {
|
||||
it('should use custom placeholder when provided', () => {
|
||||
const wrapper = mountComponent({ placeholder: 'Custom search...' })
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Custom search...')
|
||||
renderComponent({ placeholder: 'Custom search...' })
|
||||
expect(
|
||||
screen.getByPlaceholderText('Custom search...')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use i18n placeholder when not provided', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
|
||||
expect(input.attributes('placeholder')).toBe('Search...')
|
||||
renderComponent()
|
||||
expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('autofocus', () => {
|
||||
it('should pass autofocus prop to ComboboxInput', () => {
|
||||
const wrapper = mountComponent({ autofocus: true })
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('autofocus')).toBeDefined()
|
||||
renderComponent({ autofocus: true })
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('autofocus')
|
||||
})
|
||||
|
||||
it('should not autofocus by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('autofocus')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('focus method', () => {
|
||||
it('should expose focus method via ref', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.vm.focus).toBeDefined()
|
||||
renderComponent()
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).not.toHaveAttribute('autofocus')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear button', () => {
|
||||
it('shows search icon when value is empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: '' })
|
||||
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(false)
|
||||
renderComponent({ modelValue: '' })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows clear button when value is not empty', () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(true)
|
||||
renderComponent({ modelValue: 'test' })
|
||||
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears value when clear button is clicked', async () => {
|
||||
const wrapper = mountComponent({ modelValue: 'test' })
|
||||
const clearButton = wrapper.find('button')
|
||||
await clearButton.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
|
||||
const onUpdate = vi.fn()
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
renderComponent({
|
||||
modelValue: 'test',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
expect(onUpdate).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user