Files
ComfyUI_frontend/src/components/TopMenuSection.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

615 lines
19 KiB
TypeScript

/* eslint-disable testing-library/no-container */
/* eslint-disable testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const mockData = vi.hoisted(() => ({
isLoggedIn: false,
isDesktop: false,
setShowConflictRedDot: (_value: boolean) => {}
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => {
return {
isLoggedIn: computed(() => mockData.isLoggedIn)
}
}
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isNightly: false,
get isDesktop() {
return mockData.isDesktop
}
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => ({
shouldShowRedDot: computed(() => true)
})
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => {
const shouldShowConflictRedDot = ref(false)
mockData.setShowConflictRedDot = (value: boolean) => {
shouldShowConflictRedDot.value = value
}
return {
useConflictAcknowledgment: () => ({
shouldShowRedDot: shouldShowConflictRedDot
})
}
}
)
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: computed(() => true),
openManager: vi.fn()
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
currentUser: null,
loading: false
}))
}))
vi.mock('@/scripts/app', () => ({
app: {
menu: {
element: document.createElement('div')
}
}
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
attachTo?: HTMLElement
}
function createWrapper({
pinia = createTestingPinia({ createSpy: vi.fn }),
stubs = {},
attachTo
}: WrapperOptions = {}) {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue',
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
}
}
}
})
const user = userEvent.setup()
const renderOptions: Record<string, unknown> = {
global: {
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
QueueNotificationBannerHost: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template:
'<div data-testid="context-menu" :data-model="JSON.stringify(model)" />'
},
...stubs
},
directives: {
tooltip: () => {}
}
}
}
if (attachTo) {
renderOptions.container = attachTo.appendChild(
document.createElement('div')
)
}
const { container, unmount } = render(TopMenuSection, renderOptions)
return { container, unmount, user }
}
function getLegacyCommandsContainer(container: Element): HTMLElement {
const legacyContainer = container.querySelector(
'[data-testid="legacy-topbar-container"]'
)
if (!(legacyContainer instanceof HTMLElement)) {
throw new Error('Expected legacy commands container to be present')
}
return legacyContainer
}
function createJob(id: string, status: JobStatus): JobListItem {
return {
id,
status,
create_time: 0,
priority: 0
}
}
function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
function createComfyActionbarStub(actionbarTarget: HTMLElement) {
return defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
}
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
mockData.isDesktop = false
mockData.isLoggedIn = false
mockData.setShowConflictRedDot(false)
})
describe('authentication state', () => {
function createLegacyTabBarWrapper() {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
)
return createWrapper({ pinia })
}
describe('when user is logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = true
})
it('should display CurrentUserButton and not display LoginButton', () => {
const { container } = createLegacyTabBarWrapper()
expect(
container.querySelector('current-user-button-stub')
).not.toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
})
})
describe('when user is not logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = false
})
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('login-button-stub')).not.toBeNull()
expect(container.querySelector('current-user-button-stub')).toBeNull()
})
})
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('current-user-button-stub')).toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
})
})
})
})
it('shows the active jobs label with the current count', async () => {
createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
queueStore.runningTasks = [
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
]
await nextTick()
const queueButton = screen.getByTestId('queue-overlay-toggle')
expect(queueButton.textContent).toContain('3 active')
expect(screen.getByTestId('active-jobs-indicator')).toBeTruthy()
})
it('hides the active jobs indicator when no jobs are active', () => {
createWrapper()
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const { container } = createWrapper({ pinia })
await nextTick()
expect(screen.getByTestId('queue-overlay-toggle')).toBeTruthy()
expect(container.querySelector('queue-progress-overlay-stub')).toBeNull()
})
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const { user } = createWrapper({ pinia })
const commandStore = useCommandStore(pinia)
await user.click(screen.getByTestId('queue-overlay-toggle'))
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.Queue.ToggleOverlay'
)
})
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const { user } = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
await user.click(screen.getByTestId('queue-overlay-toggle'))
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const { user } = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = screen.getByTestId('queue-overlay-toggle')
await user.click(toggleButton)
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
await user.click(toggleButton)
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean,
showRunProgressBar = true
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders inline progress summary when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const { container } = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-inline-progress-summary-stub')
).not.toBeNull()
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const { container } = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-inline-progress-summary-stub')
).toBeNull()
})
it('does not render inline progress summary when run progress bar is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true, false)
const { container } = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-inline-progress-summary-stub')
).toBeNull()
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activeJobId = 'job-1'
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const { unmount } = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueInlineProgressSummary: false
}
})
try {
await nextTick()
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
unmount()
actionbarTarget.remove()
}
})
})
describe(QueueNotificationBannerHost, () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders queue notification banners when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const { container } = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
})
it('renders queue notification banners when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const { container } = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
})
it('renders inline summary above banners when both are visible', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const { container } = createWrapper({ pinia })
await nextTick()
const html = container.innerHTML
const inlineSummaryIndex = html.indexOf(
'queue-inline-progress-summary-stub'
)
const queueBannerIndex = html.indexOf(
'queue-notification-banner-host-stub'
)
expect(inlineSummaryIndex).toBeGreaterThan(-1)
expect(queueBannerIndex).toBeGreaterThan(-1)
expect(inlineSummaryIndex).toBeLessThan(queueBannerIndex)
})
it('does not teleport queue notification banners when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activeJobId = 'job-1'
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const { container, unmount } = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueNotificationBannerHost: true
}
})
try {
await nextTick()
expect(
actionbarTarget.querySelector('queue-notification-banner-host-stub')
).toBeNull()
expect(
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
} finally {
unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const { container } = createWrapper()
const menuEl = container.querySelector('[data-testid="context-menu"]')
const model = JSON.parse(
menuEl?.getAttribute('data-model') ?? '[]'
) as MenuItem[]
expect(model[0]?.label).toBe('Clear queue')
expect(model[0]?.disabled).toBe(true)
})
it('enables the clear queue context menu item when queued jobs exist', async () => {
const { container } = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
await nextTick()
const menuEl = container.querySelector('[data-testid="context-menu"]')
const model = JSON.parse(
menuEl?.getAttribute('data-model') ?? '[]'
) as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
it('shows manager red dot only for manager conflicts', async () => {
const { container } = createWrapper()
// Release red dot is mocked as true globally for this test file.
expect(container.querySelector('span.bg-red-500')).toBeNull()
mockData.setShowConflictRedDot(true)
await nextTick()
expect(container.querySelector('span.bg-red-500')).not.toBeNull()
})
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const rafCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
if (key === 'Comfy.RightSidePanel.IsOpen') return true
return undefined
})
const { container, unmount } = createWrapper({
pinia,
attachTo: document.body
})
try {
await nextTick()
const actionbarContainer = container.querySelector('.actionbar-container')
expect(actionbarContainer).not.toBeNull()
expect(actionbarContainer!.classList).toContain('w-0')
const legacyContainer = getLegacyCommandsContainer(container)
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
if (rafCallbacks.length > 0) {
const initialCallbacks = [...rafCallbacks]
rafCallbacks.length = 0
initialCallbacks.forEach((callback) => callback(0))
await nextTick()
}
querySpy.mockClear()
querySpy.mockReturnValue(document.createElement('div'))
for (let index = 0; index < 3; index++) {
const outer = document.createElement('div')
const inner = document.createElement('div')
inner.textContent = `legacy-${index}`
outer.appendChild(inner)
legacyContainer.appendChild(outer)
}
await vi.waitFor(() => {
expect(rafCallbacks.length).toBeGreaterThan(0)
})
expect(querySpy).not.toHaveBeenCalled()
const callbacks = [...rafCallbacks]
rafCallbacks.length = 0
callbacks.forEach((callback) => callback(0))
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer!.classList).toContain('px-2')
} finally {
unmount()
vi.unstubAllGlobals()
}
})
})