Files
ComfyUI_frontend/src/components/rightSidePanel/errors/MissingNodeCard.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

395 lines
13 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const mockIsCloud = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockMissingCoreNodes = vi.hoisted(() => ({
value: {} as Record<string, { type: string }[]>
}))
const mockSystemStats = vi.hoisted(() => ({
value: null as { system?: { comfyui_version?: string } } | null
}))
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useMissingNodes',
() => ({
useMissingNodes: () => ({
missingCoreNodes: mockMissingCoreNodes,
missingNodePacks: { value: [] },
isLoading: { value: false },
error: { value: null },
hasMissingNodes: { value: false }
})
})
)
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: () => ({
get systemStats() {
return mockSystemStats.value
}
})
}))
const mockApplyChanges = vi.hoisted(() => vi.fn())
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
get isRestarting() {
return mockIsRestarting.value
},
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
})
}))
vi.mock('./MissingPackGroupRow.vue', () => ({
default: {
name: 'MissingPackGroupRow',
template: `<div class="pack-row" data-testid="pack-row"
:data-show-info-button="String(showInfoButton)"
:data-show-node-id-badge="String(showNodeIdBadge)"
>
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
</div>`,
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
emits: ['locate-node', 'open-manager-info']
}
}))
import MissingNodeCard from './MissingNodeCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingNodePacks: {
ossMessage: 'Missing node packs detected. Install them.',
cloudMessage: 'Unsupported node packs detected.',
ossManagerDisabledHint:
'To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.',
applyChanges: 'Apply Changes'
}
},
loadWorkflowWarning: {
outdatedVersion:
'Some nodes require a newer version of ComfyUI (current: {version}).',
outdatedVersionGeneric:
'Some nodes require a newer version of ComfyUI.',
coreNodesFromVersion: 'Requires ComfyUI {version}:',
unknownVersion: 'unknown'
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makePackGroups(count = 2): MissingPackGroup[] {
return Array.from({ length: count }, (_, i) => ({
packId: `pack-${i}`,
nodeTypes: [
{ type: `MissingNode${i}`, nodeId: String(i), isReplaceable: false }
],
isResolving: false
}))
}
function renderCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
const user = userEvent.setup()
const result = render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}
}
})
return { ...result, user }
}
describe('MissingNodeCard', () => {
beforeEach(() => {
mockApplyChanges.mockClear()
mockIsPackInstalled.mockReset()
mockIsPackInstalled.mockReturnValue(false)
mockIsCloud.value = false
mockShouldShowManagerButtons.value = false
mockIsRestarting.value = false
mockMissingCoreNodes.value = {}
mockSystemStats.value = null
})
describe('Rendering & Props', () => {
it('renders cloud message when isCloud is true', () => {
mockIsCloud.value = true
renderCard()
expect(
screen.getByText('Unsupported node packs detected.')
).toBeInTheDocument()
})
it('renders OSS message when isCloud is false', () => {
renderCard()
expect(
screen.getByText('Missing node packs detected. Install them.')
).toBeInTheDocument()
})
it('renders correct number of MissingPackGroupRow components', () => {
renderCard({ missingPackGroups: makePackGroups(3) })
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)
})
it('renders zero rows when missingPackGroups is empty', () => {
renderCard({ missingPackGroups: [] })
expect(screen.queryAllByTestId('pack-row')).toHaveLength(0)
})
it('passes props correctly to MissingPackGroupRow children', () => {
renderCard({
showInfoButton: true,
showNodeIdBadge: true
})
const row = screen.getAllByTestId('pack-row')[0]
expect(row.getAttribute('data-show-info-button')).toBe('true')
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
})
})
describe('Manager Disabled Hint', () => {
it('shows hint when OSS and manager is disabled (showInfoButton false)', () => {
mockIsCloud.value = false
renderCard({ showInfoButton: false })
expect(
screen.getByText('pip install -U --pre comfyui-manager')
).toBeInTheDocument()
expect(screen.getByText('--enable-manager')).toBeInTheDocument()
})
it('hides hint when manager is enabled (showInfoButton true)', () => {
mockIsCloud.value = false
renderCard({ showInfoButton: true })
expect(screen.queryByText('--enable-manager')).not.toBeInTheDocument()
})
it('hides hint on Cloud even when showInfoButton is false', () => {
mockIsCloud.value = true
renderCard({ showInfoButton: false })
expect(screen.queryByText('--enable-manager')).not.toBeInTheDocument()
})
})
describe('Apply Changes Section', () => {
it('hides Apply Changes when manager is not enabled', () => {
mockShouldShowManagerButtons.value = false
renderCard()
expect(screen.queryByText('Apply Changes')).not.toBeInTheDocument()
})
it('hides Apply Changes when manager enabled but no packs pending', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
renderCard()
expect(screen.queryByText('Apply Changes')).not.toBeInTheDocument()
})
it('shows Apply Changes when at least one pack is pending restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
renderCard()
expect(screen.getByText('Apply Changes')).toBeInTheDocument()
})
it('displays spinner during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
renderCard()
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('disables button during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
renderCard()
expect(
screen.getByRole('button', { name: /apply changes/i })
).toBeDisabled()
})
it('calls applyChanges when Apply Changes button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const { user } = renderCard()
await user.click(screen.getByRole('button', { name: /apply changes/i }))
expect(mockApplyChanges).toHaveBeenCalledOnce()
})
})
describe('Event Handling', () => {
it('emits locateNode when child emits locate-node', async () => {
const onLocateNode = vi.fn()
const user = userEvent.setup()
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onLocateNode
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
}
}
})
await user.click(screen.getAllByTestId('locate-node')[0])
expect(onLocateNode).toHaveBeenCalledWith('0')
})
it('emits openManagerInfo when child emits open-manager-info', async () => {
const onOpenManagerInfo = vi.fn()
const user = userEvent.setup()
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onOpenManagerInfo
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
}
}
})
await user.click(screen.getAllByTestId('open-manager-info')[0])
expect(onOpenManagerInfo).toHaveBeenCalledWith('pack-0')
})
})
describe('Core Node Version Warning', () => {
it('does not render warning when no missing core nodes', () => {
const { container } = renderCard()
expect(container.textContent).not.toContain('newer version of ComfyUI')
})
it('renders warning with version when missing core nodes exist', () => {
mockMissingCoreNodes.value = {
'1.2.0': [{ type: 'TestNode' }]
}
mockSystemStats.value = { system: { comfyui_version: '1.0.0' } }
const { container } = renderCard()
expect(container.textContent).toContain('(current: 1.0.0)')
expect(container.textContent).toContain('Requires ComfyUI 1.2.0:')
expect(container.textContent).toContain('TestNode')
})
it('renders generic message when version is unavailable', () => {
mockMissingCoreNodes.value = {
'1.2.0': [{ type: 'TestNode' }]
}
renderCard()
expect(
screen.getByText('Some nodes require a newer version of ComfyUI.')
).toBeInTheDocument()
})
it('does not render warning on Cloud', () => {
mockIsCloud.value = true
mockMissingCoreNodes.value = {
'1.2.0': [{ type: 'TestNode' }]
}
const { container } = renderCard()
expect(container.textContent).not.toContain('newer version of ComfyUI')
})
it('deduplicates and sorts node names within a version', () => {
mockMissingCoreNodes.value = {
'1.2.0': [
{ type: 'ZebraNode' },
{ type: 'AlphaNode' },
{ type: 'ZebraNode' }
]
}
const { container } = renderCard()
expect(container.textContent).toContain('AlphaNode, ZebraNode')
// eslint-disable-next-line testing-library/no-container
expect(container.textContent?.match(/ZebraNode/g)).toHaveLength(1)
})
it('sorts versions in descending order', () => {
mockMissingCoreNodes.value = {
'1.1.0': [{ type: 'Node1' }],
'1.3.0': [{ type: 'Node3' }],
'1.2.0': [{ type: 'Node2' }]
}
const { container } = renderCard()
const text = container.textContent ?? ''
const v13 = text.indexOf('1.3.0')
const v12 = text.indexOf('1.2.0')
const v11 = text.indexOf('1.1.0')
expect(v13).toBeLessThan(v12)
expect(v12).toBeLessThan(v11)
})
it('handles empty string version key without crashing', () => {
mockMissingCoreNodes.value = {
'': [{ type: 'NoVersionNode' }],
'1.2.0': [{ type: 'VersionedNode' }]
}
const { container } = renderCard()
expect(container.textContent).toContain('Requires ComfyUI 1.2.0:')
expect(container.textContent).toContain('VersionedNode')
expect(container.textContent).toContain('unknown')
expect(container.textContent).toContain('NoVersionNode')
})
})
})