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:
Alexander Brown
2026-03-26 19:46:31 -07:00
committed by GitHub
parent 08b1199265
commit 3de387429a
11 changed files with 480 additions and 459 deletions

View File

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