mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 13:41:59 +00:00
test: migrate 132 test files from @vue/test-utils to @testing-library/vue (#10965)
## Summary Migrate 132 test files from `@vue/test-utils` (VTU) to `@testing-library/vue` (VTL) with `@testing-library/user-event`, adopting user-centric behavioral testing patterns across the codebase. ## Changes - **What**: Systematic migration of component/unit tests from VTU's `mount`/`wrapper` API to VTL's `render`/`screen`/`userEvent` API across 132 files in `src/` - **Breaking**: None — test-only changes, no production code affected ### Migration breakdown | Batch | Files | Description | |-------|-------|-------------| | 1 | 19 | Simple render/assert tests | | 2A | 16 | Interactive tests with user events | | 2B-1 | 14 | Interactive tests (continued) | | 2B-2 | 32 | Interactive tests (continued) | | 3A–3E | 51 | Complex tests (stores, composables, heavy mocking) | | Lint fix | 7 | `await` on `fireEvent` calls for `no-floating-promises` | | Review fixes | 15 | Address CodeRabbit feedback (3 rounds) | ### Review feedback addressed - Removed class-based assertions (`text-ellipsis`, `pr-3`, `.pi-save`, `.skeleton`, `.bg-black\/15`, Tailwind utilities) in favor of behavioral/accessible queries - Added null guards before `querySelector` casts - Added `expect(roots).toHaveLength(N)` guards before indexed NodeList access - Wrapped fake timer tests in `try/finally` for guaranteed cleanup - Split double-render tests into focused single-render tests - Replaced CSS class selectors with `screen.getByText`/`screen.getByRole` queries - Updated stubs to use semantic `role`/`aria-label` instead of CSS classes - Consolidated redundant edge-case tests - Removed manual `document.body.appendChild` in favor of VTL container management - Used distinct mock return values to verify command wiring ### VTU holdouts (2 files) These files intentionally retain `@vue/test-utils` because their components use `<script setup>` without `defineExpose`, making internal computed properties and methods inaccessible via VTL: 1. **`NodeWidgets.test.ts`** — partial VTU for `vm.processedWidgets` 2. **`WidgetSelectDropdown.test.ts`** — full VTU for heavy `wrapper.vm.*` access ## Follow-up Deferred items (`ComponentProps` typing, camelCase listener props) tracked in #10966. ## Review Focus - Test correctness: all migrated tests preserve original behavioral coverage - VTL idioms: proper use of `screen` queries, `userEvent`, and accessibility-based selectors - The 2 VTU holdout files are intentional, not oversights ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10965-test-migrate-132-test-files-from-vue-test-utils-to-testing-library-vue-33c6d73d36508199a6a7e513cf5d8296) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
/* eslint-disable testing-library/no-container */
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -25,6 +28,24 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const WidgetStub = {
|
||||
name: 'WidgetStub',
|
||||
props: ['widget', 'nodeId', 'nodeType', 'modelValue'],
|
||||
template:
|
||||
'<div class="widget-stub" :data-node-type="nodeType">{{ nodeType }}</div>'
|
||||
}
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
async (importOriginal) => {
|
||||
const original = await importOriginal()
|
||||
return {
|
||||
...(original as Record<string, unknown>),
|
||||
getComponent: () => WidgetStub
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
describe('NodeWidgets', () => {
|
||||
const createMockWidget = (
|
||||
overrides: Partial<SafeWidgetData> = {}
|
||||
@@ -56,19 +77,18 @@ describe('NodeWidgets', () => {
|
||||
outputs: []
|
||||
})
|
||||
|
||||
const mountComponent = (nodeData?: VueNodeData, setupStores?: () => void) => {
|
||||
function renderComponent(nodeData?: VueNodeData, setupStores?: () => void) {
|
||||
const pinia = createTestingPinia({ stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
setupStores?.()
|
||||
|
||||
return mount(NodeWidgets, {
|
||||
return render(NodeWidgets, {
|
||||
props: {
|
||||
nodeData
|
||||
},
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
// Stub InputSlot to avoid complex slot registration dependencies
|
||||
InputSlot: true
|
||||
},
|
||||
mocks: {
|
||||
@@ -78,6 +98,21 @@ describe('NodeWidgets', () => {
|
||||
})
|
||||
}
|
||||
|
||||
function mountComponent(nodeData?: VueNodeData, setupStores?: () => void) {
|
||||
const pinia = createTestingPinia({ stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
setupStores?.()
|
||||
|
||||
return mount(NodeWidgets, {
|
||||
props: { nodeData },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: { InputSlot: true },
|
||||
mocks: { $t: (key: string) => key }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getBorderStyles = (wrapper: ReturnType<typeof mount>) =>
|
||||
fromAny<{ processedWidgets: unknown[] }, unknown>(
|
||||
wrapper.vm
|
||||
@@ -96,41 +131,29 @@ describe('NodeWidgets', () => {
|
||||
it('passes node type to widget components', () => {
|
||||
const widget = createMockWidget()
|
||||
const nodeData = createMockNodeData('CheckpointLoaderSimple', [widget])
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const { container } = renderComponent(nodeData)
|
||||
|
||||
// Find the dynamically rendered widget component
|
||||
const widgetComponent = wrapper.find('.lg-node-widget')
|
||||
expect(widgetComponent.exists()).toBe(true)
|
||||
|
||||
// Verify node-type prop is passed
|
||||
const component = widgetComponent.findComponent({ name: 'WidgetSelect' })
|
||||
if (component.exists()) {
|
||||
expect(component.props('nodeType')).toBe('CheckpointLoaderSimple')
|
||||
}
|
||||
const stub = container.querySelector('.widget-stub')
|
||||
expect(stub).not.toBeNull()
|
||||
expect(stub!.getAttribute('data-node-type')).toBe(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes empty string when nodeData is undefined', () => {
|
||||
const wrapper = mountComponent(undefined)
|
||||
it('renders no widgets when nodeData is undefined', () => {
|
||||
const { container } = renderComponent(undefined)
|
||||
|
||||
// No widgets should be rendered
|
||||
const widgetComponents = wrapper.findAll('.lg-node-widget')
|
||||
expect(widgetComponents).toHaveLength(0)
|
||||
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('passes empty string when nodeData.type is undefined', () => {
|
||||
it('passes empty string when nodeData.type is empty', () => {
|
||||
const widget = createMockWidget()
|
||||
const nodeData = createMockNodeData('', [widget])
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const { container } = renderComponent(nodeData)
|
||||
|
||||
const widgetComponent = wrapper.find('.lg-node-widget')
|
||||
if (widgetComponent.exists()) {
|
||||
const component = widgetComponent.findComponent({
|
||||
name: 'WidgetSelect'
|
||||
})
|
||||
if (component.exists()) {
|
||||
expect(component.props('nodeType')).toBe('')
|
||||
}
|
||||
}
|
||||
const stub = container.querySelector('.widget-stub')
|
||||
expect(stub).not.toBeNull()
|
||||
expect(stub!.getAttribute('data-node-type')).toBe('')
|
||||
})
|
||||
|
||||
it.for(['CheckpointLoaderSimple', 'LoraLoader', 'VAELoader', 'KSampler'])(
|
||||
@@ -138,17 +161,11 @@ describe('NodeWidgets', () => {
|
||||
(nodeType) => {
|
||||
const widget = createMockWidget()
|
||||
const nodeData = createMockNodeData(nodeType, [widget])
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const { container } = renderComponent(nodeData)
|
||||
|
||||
const widgetComponent = wrapper.find('.lg-node-widget')
|
||||
expect(widgetComponent.exists()).toBe(true)
|
||||
|
||||
const component = widgetComponent.findComponent({
|
||||
name: 'WidgetSelect'
|
||||
})
|
||||
if (component.exists()) {
|
||||
expect(component.props('nodeType')).toBe(nodeType)
|
||||
}
|
||||
const stub = container.querySelector('.widget-stub')
|
||||
expect(stub).not.toBeNull()
|
||||
expect(stub!.getAttribute('data-node-type')).toBe(nodeType)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -184,9 +201,9 @@ describe('NodeWidgets', () => {
|
||||
distinct
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const { container } = renderComponent(nodeData)
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
|
||||
@@ -213,9 +230,9 @@ describe('NodeWidgets', () => {
|
||||
visibleDuplicate
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const { container } = renderComponent(nodeData)
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(1)
|
||||
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not deduplicate entries that share names but have different widget types', () => {
|
||||
@@ -240,9 +257,9 @@ describe('NodeWidgets', () => {
|
||||
comboWidget
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const { container } = renderComponent(nodeData)
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
|
||||
@@ -269,9 +286,9 @@ describe('NodeWidgets', () => {
|
||||
secondTransientEntry
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const { container } = renderComponent(nodeData)
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not deduplicate promoted duplicates that differ only by disambiguating source identity', () => {
|
||||
@@ -296,9 +313,9 @@ describe('NodeWidgets', () => {
|
||||
firstPromoted,
|
||||
secondPromoted
|
||||
])
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const { container } = renderComponent(nodeData)
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('applies promoted border styling to intermediate promoted widgets using host node identity', async () => {
|
||||
@@ -358,7 +375,7 @@ describe('NodeWidgets', () => {
|
||||
})
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const { container } = renderComponent(nodeData)
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget('graph-test', {
|
||||
nodeId: 'test_node',
|
||||
@@ -373,7 +390,7 @@ describe('NodeWidgets', () => {
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(0)
|
||||
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps AppInput ids mapped to node identity for selection', () => {
|
||||
@@ -382,9 +399,32 @@ describe('NodeWidgets', () => {
|
||||
createMockWidget({ nodeId: 'test_node', name: 'seed_b', type: 'text' })
|
||||
])
|
||||
|
||||
const wrapper = mountComponent(nodeData)
|
||||
const appInputWrappers = wrapper.findAllComponents({ name: 'AppInput' })
|
||||
const ids = appInputWrappers.map((component) => component.props('id'))
|
||||
const { container } = render(NodeWidgets, {
|
||||
props: { nodeData },
|
||||
global: {
|
||||
plugins: [
|
||||
(() => {
|
||||
const pinia = createTestingPinia({ stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
return pinia
|
||||
})()
|
||||
],
|
||||
stubs: {
|
||||
InputSlot: true,
|
||||
AppInput: {
|
||||
props: ['id', 'name', 'enable'],
|
||||
template: '<div class="app-input-stub" :data-id="id"><slot /></div>'
|
||||
}
|
||||
},
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
}
|
||||
})
|
||||
const appInputElements = container.querySelectorAll('.app-input-stub')
|
||||
const ids = Array.from(appInputElements).map((el) =>
|
||||
el.getAttribute('data-id')
|
||||
)
|
||||
|
||||
expect(ids).toStrictEqual(['test_node', 'test_node'])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user