Files
ComfyUI_frontend/src/components/graph/DomWidgets.test.ts
Alexander Brown f90d6cf607 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>
2026-04-08 19:21:42 -07:00

211 lines
5.9 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
type TestWidget = BaseDOMWidget<object | string>
function createNode(
graph: LGraph,
id: number,
title: string,
pos: [number, number]
) {
const node = new LGraphNode(title)
node.id = id
node.pos = [...pos]
node.size = [240, 120]
graph.add(node)
return node
}
function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
return fromPartial<TestWidget>({
id,
node,
name: 'test_widget',
type: 'custom',
value: '',
options: {},
y,
width: 120,
computedHeight: 40,
margin: 10,
isVisible: () => true
})
}
function createCanvas(graph: LGraph): LGraphCanvas {
return fromPartial<LGraphCanvas>({
graph,
low_quality: false,
read_only: false,
isNodeVisible: vi.fn(() => true)
})
}
function drawFrame(canvas: LGraphCanvas) {
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
}
describe('DomWidgets transition grace characterization', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('applies transition grace for exactly one frame when override exists but is not active', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
const widget = createWidget('widget-transition', interiorNode, 14)
const overrideWidget = createWidget('override-widget', overrideNode, 22)
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(widget.id)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
widgetState.visible = true
widgetState.pos = [321, 654]
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
drawFrame(canvas)
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([321, 654])
drawFrame(canvas)
expect(widgetState.visible).toBe(false)
})
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
const widget = createWidget('widget-override-active', interiorNode, 8)
const overrideWidget = createWidget(
'override-position-source',
overrideNode,
18
)
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(widget.id)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const canvas = createCanvas(graphB)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
drawFrame(canvas)
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([310, 428])
})
it('cleans orphaned transition-grace ids after widget removal', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
const overrideWidget = createWidget(
'shared-override-widget',
overrideNode,
14
)
domWidgetStore.registerWidget(oldWidget)
domWidgetStore.setPositionOverride(oldWidget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(oldWidget.id)
drawFrame(canvas)
domWidgetStore.unregisterWidget(oldWidget.id)
drawFrame(canvas)
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
domWidgetStore.registerWidget(replacementWidget)
domWidgetStore.setPositionOverride(replacementWidget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(replacementWidget.id)
const replacementState = domWidgetStore.widgetStates.get(
replacementWidget.id
)
if (!replacementState) throw new Error('Replacement widget missing state')
replacementState.visible = true
replacementState.pos = [999, 999]
drawFrame(canvas)
expect(replacementState.visible).toBe(true)
expect(replacementState.pos).toEqual([999, 999])
})
})