chore: migrate tests from tests-ui/ to colocate with source files (#7811)

## Summary

Migrates all unit tests from `tests-ui/` to colocate with their source
files in `src/`, improving discoverability and maintainability.

## Changes

- **What**: Relocated all unit tests to be adjacent to the code they
test, following the `<source>.test.ts` naming convention
- **Config**: Updated `vitest.config.ts` to remove `tests-ui` include
pattern and `@tests-ui` alias
- **Docs**: Moved testing documentation to `docs/testing/` with updated
paths and patterns

## Review Focus

- Migration patterns documented in
`temp/plans/migrate-tests-ui-to-src.md`
- Tests use `@/` path aliases instead of relative imports
- Shared fixtures placed in `__fixtures__/` directories

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7811-chore-migrate-tests-from-tests-ui-to-colocate-with-source-files-2da6d73d36508147a4cce85365dee614)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-01-05 16:32:24 -08:00
committed by GitHub
parent 832588c7a9
commit 10feb1fd5b
272 changed files with 483 additions and 1239 deletions

View File

@@ -0,0 +1,103 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => {
return {
isLoggedIn: computed(() => mockData.isLoggedIn)
}
}
}))
vi.mock('@/utils/envUtil')
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
currentUser: null,
loading: false
}))
}))
function createWrapper() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue'
}
}
}
}
})
return mount(TopMenuSection, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
CurrentUserButton: true,
LoginButton: true
},
directives: {
tooltip: () => {}
}
}
})
}
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('authentication state', () => {
describe('when user is logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = true
})
it('should display CurrentUserButton and not display LoginButton', () => {
const wrapper = createWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})
})
describe('when user is not logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = false
})
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
vi.mocked(isElectron).mockReturnValue(true)
const wrapper = createWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
})
})
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const wrapper = createWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})
})
})
})
})

View File

@@ -0,0 +1,80 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
// Mock ShortcutsList component
vi.mock('@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue', () => ({
default: {
name: 'ShortcutsList',
props: ['commands', 'subcategories', 'columns'],
template:
'<div class="shortcuts-list-mock">{{ commands.length }} commands</div>'
}
}))
// Mock command store
const mockCommands: ComfyCommandImpl[] = [
{
id: 'Workflow.New',
label: 'New Workflow',
category: 'essentials'
} as ComfyCommandImpl,
{
id: 'Node.Add',
label: 'Add Node',
category: 'essentials'
} as ComfyCommandImpl,
{
id: 'Queue.Clear',
label: 'Clear Queue',
category: 'essentials'
} as ComfyCommandImpl,
{
id: 'Other.Command',
label: 'Other Command',
category: 'view-controls',
function: vi.fn(),
icon: 'pi pi-test',
tooltip: 'Test tooltip',
menubarLabel: 'Other Command',
keybinding: null
} as ComfyCommandImpl
]
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
commands: mockCommands
})
}))
describe('EssentialsPanel', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should render ShortcutsList with essentials commands', () => {
const wrapper = mount(EssentialsPanel)
const shortcutsList = wrapper.findComponent(ShortcutsList)
expect(shortcutsList.exists()).toBe(true)
})
it('should categorize commands into subcategories', () => {
const wrapper = mount(EssentialsPanel)
const shortcutsList = wrapper.findComponent(ShortcutsList)
const subcategories = shortcutsList.props('subcategories')
expect(subcategories).toHaveProperty('workflow')
expect(subcategories).toHaveProperty('node')
expect(subcategories).toHaveProperty('queue')
expect(subcategories.workflow).toContain(mockCommands[0])
expect(subcategories.node).toContain(mockCommands[1])
expect(subcategories.queue).toContain(mockCommands[2])
})
})

View File

@@ -0,0 +1,164 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
// Mock vue-i18n
const mockT = vi.fn((key: string) => {
const translations: Record<string, string> = {
'shortcuts.subcategories.workflow': 'Workflow',
'shortcuts.subcategories.node': 'Node',
'shortcuts.subcategories.queue': 'Queue',
'shortcuts.subcategories.view': 'View',
'shortcuts.subcategories.panelControls': 'Panel Controls',
'commands.Workflow_New.label': 'New Blank Workflow'
}
return translations[key] || key
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: mockT
})
}))
describe('ShortcutsList', () => {
const mockCommands: ComfyCommandImpl[] = [
{
id: 'Workflow.New',
label: 'New Workflow',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Control', 'n']
}
}
} as ComfyCommandImpl,
{
id: 'Node.Add',
label: 'Add Node',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Shift', 'a']
}
}
} as ComfyCommandImpl,
{
id: 'Queue.Clear',
label: 'Clear Queue',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Control', 'Shift', 'c']
}
}
} as ComfyCommandImpl
]
const mockSubcategories = {
workflow: [mockCommands[0]],
node: [mockCommands[1]],
queue: [mockCommands[2]]
}
it('should render shortcuts organized by subcategories', () => {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
// Check that subcategories are rendered
expect(wrapper.text()).toContain('Workflow')
expect(wrapper.text()).toContain('Node')
expect(wrapper.text()).toContain('Queue')
// Check that commands are rendered
expect(wrapper.text()).toContain('New Blank Workflow')
})
it('should format keyboard shortcuts correctly', () => {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
// Check for formatted keys
expect(wrapper.text()).toContain('Ctrl')
expect(wrapper.text()).toContain('n')
expect(wrapper.text()).toContain('Shift')
expect(wrapper.text()).toContain('a')
expect(wrapper.text()).toContain('c')
})
it('should filter out commands without keybindings', () => {
const commandsWithoutKeybinding: ComfyCommandImpl[] = [
...mockCommands,
{
id: 'No.Keybinding',
label: 'No Keybinding',
category: 'essentials',
keybinding: null
} as ComfyCommandImpl
]
const wrapper = mount(ShortcutsList, {
props: {
commands: commandsWithoutKeybinding,
subcategories: {
...mockSubcategories,
other: [commandsWithoutKeybinding[3]]
}
}
})
expect(wrapper.text()).not.toContain('No Keybinding')
})
it('should handle special key formatting', () => {
const specialKeyCommand: ComfyCommandImpl = {
id: 'Special.Keys',
label: 'Special Keys',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Meta', 'ArrowUp', 'Enter', 'Escape', ' ']
}
}
} as ComfyCommandImpl
const wrapper = mount(ShortcutsList, {
props: {
commands: [specialKeyCommand],
subcategories: {
special: [specialKeyCommand]
}
}
})
const text = wrapper.text()
expect(text).toContain('Cmd') // Meta -> Cmd
expect(text).toContain('↑') // ArrowUp -> ↑
expect(text).toContain('↵') // Enter -> ↵
expect(text).toContain('Esc') // Escape -> Esc
expect(text).toContain('Space') // ' ' -> Space
})
it('should use fallback subcategory titles', () => {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: {
unknown: [mockCommands[0]]
}
}
})
expect(wrapper.text()).toContain('unknown')
})
})

View File

@@ -0,0 +1,219 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
// Mock xterm and related modules
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn().mockImplementation(() => ({
open: vi.fn(),
dispose: vi.fn(),
onSelectionChange: vi.fn(() => {
// Return a disposable
return {
dispose: vi.fn()
}
}),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn(),
loadAddon: vi.fn()
})),
IDisposable: vi.fn()
}))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: vi.fn().mockImplementation(() => ({
fit: vi.fn(),
proposeDimensions: vi.fn(() => ({ rows: 24, cols: 80 }))
}))
}))
const mockTerminal = {
open: vi.fn(),
dispose: vi.fn(),
onSelectionChange: vi.fn(() => ({
dispose: vi.fn()
})),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn()
}
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
useTerminal: vi.fn(() => ({
terminal: mockTerminal,
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
}))
}))
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn(() => false),
electronAPI: vi.fn(() => null)
}))
// Mock clipboard API
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: vi.fn().mockResolvedValue(undefined)
},
configurable: true
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
serverStart: {
copySelectionTooltip: 'Copy selection',
copyAllTooltip: 'Copy all'
}
}
}
})
const mountBaseTerminal = () => {
return mount(BaseTerminal, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
Button: {
template: '<button v-bind="$attrs"><slot /></button>',
props: ['icon', 'severity', 'size']
}
}
}
})
}
describe('BaseTerminal', () => {
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
wrapper?.unmount()
})
it('emits created event on mount', () => {
wrapper = mountBaseTerminal()
expect(wrapper.emitted('created')).toBeTruthy()
expect(wrapper.emitted('created')![0]).toHaveLength(2)
})
it('emits unmounted event on unmount', () => {
wrapper = mountBaseTerminal()
wrapper.unmount()
expect(wrapper.emitted('unmounted')).toBeTruthy()
})
it('button exists and has correct initial state', async () => {
wrapper = mountBaseTerminal()
const button = wrapper.find('button[aria-label]')
expect(button.exists()).toBe(true)
expect(button.classes()).toContain('opacity-0')
expect(button.classes()).toContain('pointer-events-none')
})
it('shows correct tooltip when no selection', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy all')
})
it('shows correct tooltip when selection exists', async () => {
mockTerminal.hasSelection.mockReturnValue(true)
wrapper = mountBaseTerminal()
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
.calls[0][0]
selectionCallback()
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy selection')
})
it('copies selected text when selection exists', async () => {
const selectedText = 'selected text'
mockTerminal.hasSelection.mockReturnValue(true)
mockTerminal.getSelection.mockReturnValue(selectedText)
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).not.toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText)
expect(mockTerminal.clearSelection).not.toHaveBeenCalled()
})
it('copies all text when no selection exists', async () => {
const allText = 'all terminal content'
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection
.mockReturnValueOnce('') // First call returns empty (no selection)
.mockReturnValueOnce(allText) // Second call after selectAll returns all text
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText)
expect(mockTerminal.clearSelection).toHaveBeenCalled()
})
it('does not copy when no text available', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection.mockReturnValue('')
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,199 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
// Mock the stores
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
const createMockNode = (type: string, version?: string): LGraphNode =>
// @ts-expect-error - Creating a partial mock of LGraphNode for testing purposes.
// We only need specific properties for our tests, not the full LGraphNode interface.
({
type,
properties: { cnr_id: 'comfy-core', ver: version },
id: 1,
title: type,
pos: [0, 0],
size: [100, 100],
flags: {},
graph: null,
mode: 0,
inputs: [],
outputs: []
})
describe('MissingCoreNodesMessage', () => {
const mockSystemStatsStore = {
systemStats: null as { system?: { comfyui_version?: string } } | null,
refetchSystemStats: vi.fn()
}
beforeEach(() => {
vi.clearAllMocks()
// Reset the mock store state
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.refetchSystemStats = vi.fn()
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
// The actual store has more properties, but we only need these for our tests.
useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
})
const mountComponent = (props = {}) => {
return mount(MissingCoreNodesMessage, {
global: {
plugins: [PrimeVue],
components: { Message },
mocks: {
$t: (key: string, params?: { version?: string }) => {
const translations: Record<string, string> = {
'loadWorkflowWarning.outdatedVersion': `Some nodes require a newer version of ComfyUI (current: ${params?.version}). Please update to use all nodes.`,
'loadWorkflowWarning.outdatedVersionGeneric':
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.',
'loadWorkflowWarning.coreNodesFromVersion': `Requires ComfyUI ${params?.version}:`
}
return translations[key] || key
}
}
},
props: {
missingCoreNodes: {},
...props
}
})
}
it('does not render when there are no missing core nodes', () => {
const wrapper = mountComponent()
expect(wrapper.findComponent(Message).exists()).toBe(false)
})
it('renders message when there are missing core nodes', async () => {
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
expect(wrapper.findComponent(Message).exists()).toBe(true)
})
it('displays current ComfyUI version when available', async () => {
// Set systemStats directly (store auto-fetches with useAsyncState)
mockSystemStatsStore.systemStats = {
system: { comfyui_version: '1.0.0' }
}
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
// Wait for component to render
await nextTick()
// No need to check if fetchSystemStats was called since useAsyncState auto-fetches
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
)
})
it('displays generic message when version is unavailable', async () => {
// No systemStats set - version unavailable
mockSystemStatsStore.systemStats = null
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
// Wait for the async operations to complete
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.'
)
})
it('groups nodes by version and displays them', async () => {
const missingCoreNodes = {
'1.2.0': [
createMockNode('NodeA', '1.2.0'),
createMockNode('NodeB', '1.2.0')
],
'1.3.0': [createMockNode('NodeC', '1.3.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
expect(text).toContain('Requires ComfyUI 1.3.0:')
expect(text).toContain('NodeC')
expect(text).toContain('Requires ComfyUI 1.2.0:')
expect(text).toContain('NodeA, NodeB')
})
it('sorts versions in descending order', async () => {
const missingCoreNodes = {
'1.1.0': [createMockNode('Node1', '1.1.0')],
'1.3.0': [createMockNode('Node3', '1.3.0')],
'1.2.0': [createMockNode('Node2', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
const version13Index = text.indexOf('1.3.0')
const version12Index = text.indexOf('1.2.0')
const version11Index = text.indexOf('1.1.0')
expect(version13Index).toBeLessThan(version12Index)
expect(version12Index).toBeLessThan(version11Index)
})
it('removes duplicate node names within the same version', async () => {
const missingCoreNodes = {
'1.2.0': [
createMockNode('DuplicateNode', '1.2.0'),
createMockNode('DuplicateNode', '1.2.0'),
createMockNode('UniqueNode', '1.2.0')
]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
// Should only appear once in the sorted list
expect(text).toContain('DuplicateNode, UniqueNode')
// Count occurrences of 'DuplicateNode' - should be only 1
const matches = text.match(/DuplicateNode/g) || []
expect(matches.length).toBe(1)
})
it('handles nodes with missing version info', async () => {
const missingCoreNodes = {
'': [createMockNode('NoVersionNode')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
expect(wrapper.text()).toContain('Requires ComfyUI unknown:')
expect(wrapper.text()).toContain('NoVersionNode')
})
})

View File

@@ -0,0 +1,48 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const mountOption = (
props?: Partial<{ credits: number; description: string; selected: boolean }>
) =>
mount(CreditTopUpOption, {
props: {
credits: 1000,
description: '~100 videos*',
selected: false,
...props
},
global: {
plugins: [i18n]
}
})
describe('CreditTopUpOption', () => {
it('renders credit amount and description', () => {
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
expect(wrapper.text()).toContain('5,000')
expect(wrapper.text()).toContain('~500 videos*')
})
it('applies unselected styling when not selected', () => {
const wrapper = mountOption({ selected: false })
expect(wrapper.find('div').classes()).toContain(
'bg-component-node-disabled'
)
expect(wrapper.find('div').classes()).toContain('border-transparent')
})
it('emits select event when clicked', async () => {
const wrapper = mountOption()
await wrapper.find('div').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
})
})

View File

@@ -0,0 +1,159 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ZoomControlsModal from '@/components/graph/modals/ZoomControlsModal.vue'
// Mock functions
const mockExecute = vi.fn()
const mockGetCommand = vi.fn().mockReturnValue({
keybinding: {
combo: {
getKeySequences: () => ['Ctrl', '+']
}
}
})
const mockFormatKeySequence = vi.fn().mockReturnValue('Ctrl+')
const mockSetAppZoom = vi.fn()
const mockSettingGet = vi.fn().mockReturnValue(true)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
// Mock dependencies
vi.mock('@/renderer/extensions/minimap/composables/useMinimap', () => ({
useMinimap: () => ({
containerStyles: { value: { backgroundColor: '#fff', borderRadius: '8px' } }
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute,
getCommand: mockGetCommand,
formatKeySequence: mockFormatKeySequence
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
appScalePercentage: 100,
setAppZoomFromPercentage: mockSetAppZoom
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockSettingGet
})
}))
const createWrapper = (props = {}) => {
return mount(ZoomControlsModal, {
props: {
visible: true,
...props
},
global: {
plugins: [i18n],
stubs: {
Button: false,
InputNumber: false
}
}
})
}
describe('ZoomControlsModal', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('should execute zoom in command when zoom in button is clicked', async () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('.cursor-pointer')
const zoomInButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomIn')
)
expect(zoomInButton).toBeDefined()
await zoomInButton!.trigger('mousedown')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
})
it('should execute zoom out command when zoom out button is clicked', async () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('.cursor-pointer')
const zoomOutButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomOut')
)
expect(zoomOutButton).toBeDefined()
await zoomOutButton!.trigger('mousedown')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomOut')
})
it('should execute fit view command when fit view button is clicked', async () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('.cursor-pointer')
const fitViewButton = buttons.find((btn) =>
btn.text().includes('zoomControls.zoomToFit')
)
expect(fitViewButton).toBeDefined()
await fitViewButton!.trigger('click')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
it('should call setAppZoomFromPercentage with valid zoom input values', async () => {
const wrapper = createWrapper()
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
expect(inputNumber.exists()).toBe(true)
// Emit the input event with PrimeVue's InputNumberInputEvent structure
await inputNumber.vm.$emit('input', { value: 150 })
expect(mockSetAppZoom).toHaveBeenCalledWith(150)
})
it('should not call setAppZoomFromPercentage with invalid zoom input values', async () => {
const wrapper = createWrapper()
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
expect(inputNumber.exists()).toBe(true)
// Test out of range values
await inputNumber.vm.$emit('input', { value: 0 })
expect(mockSetAppZoom).not.toHaveBeenCalled()
await inputNumber.vm.$emit('input', { value: 1001 })
expect(mockSetAppZoom).not.toHaveBeenCalled()
})
it('should display keyboard shortcuts for commands', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('.cursor-pointer')
expect(buttons.length).toBeGreaterThan(0)
// Each command button should show the keyboard shortcut
expect(mockFormatKeySequence).toHaveBeenCalled()
})
it('should not be visible when visible prop is false', () => {
const wrapper = createWrapper({ visible: false })
expect(wrapper.find('.absolute').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,92 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined },
state: { type: String, required: true },
title: { type: String, required: true },
rightText: { type: String, default: '' },
iconName: { type: String, default: undefined },
iconImageUrl: { type: String, default: undefined },
showClear: { type: Boolean, default: undefined },
showMenu: { type: Boolean, default: undefined },
progressTotalPercent: { type: Number, default: undefined },
progressCurrentPercent: { type: Number, default: undefined },
runningNodeName: { type: String, default: undefined },
activeDetailsId: { type: String, default: null }
},
template: '<div class="queue-job-item-stub"></div>'
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: { workflow: { id: 'workflow-id' } },
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...overrides
})
const mountComponent = (groups: JobGroup[]) =>
mount(JobGroupsList, {
props: { displayedJobGroups: groups },
global: {
stubs: {
QueueJobItem: QueueJobItemStub
}
}
})
afterEach(() => {
vi.useRealTimers()
})
describe('JobGroupsList hover behavior', () => {
it('delays showing and hiding details while hovering over job rows', async () => {
vi.useFakeTimers()
const job = createJobItem({ id: 'job-d' })
const wrapper = mountComponent([
{ key: 'today', label: 'Today', items: [job] }
])
const jobItem = wrapper.findComponent(QueueJobItemStub)
jobItem.vm.$emit('details-enter', job.id)
vi.advanceTimersByTime(199)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
vi.advanceTimersByTime(1)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
wrapper.findComponent(QueueJobItemStub).vm.$emit('details-leave', job.id)
vi.advanceTimersByTime(149)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
vi.advanceTimersByTime(1)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
})
})

View File

@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore'
import type {
JobErrorDialogService,
UseJobErrorReportingOptions
} from '@/components/queue/job/useJobErrorReporting'
import * as jobErrorReporting from '@/components/queue/job/useJobErrorReporting'
const createExecutionErrorMessage = (
overrides: Partial<ExecutionErrorWsMessage> = {}
): ExecutionErrorWsMessage => ({
prompt_id: 'prompt',
timestamp: 100,
node_id: 'node-1',
node_type: 'KSampler',
executed: [],
exception_message: 'default failure',
exception_type: 'RuntimeError',
traceback: ['Trace line'],
current_inputs: {},
current_outputs: {},
...overrides
})
const createTaskWithMessages = (
messages: Array<[string, unknown]> | undefined = []
): TaskItemImpl =>
({
status: {
status_str: 'error',
completed: false,
messages
}
}) as TaskItemImpl
describe('extractExecutionError', () => {
it('returns null when task has no execution error messages', () => {
expect(jobErrorReporting.extractExecutionError(null)).toBeNull()
expect(
jobErrorReporting.extractExecutionError({
status: undefined
} as TaskItemImpl)
).toBeNull()
expect(
jobErrorReporting.extractExecutionError({
status: {
status_str: 'error',
completed: false,
messages: {} as unknown as Array<[string, unknown]>
}
} as TaskItemImpl)
).toBeNull()
expect(
jobErrorReporting.extractExecutionError(createTaskWithMessages([]))
).toBeNull()
expect(
jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_start', { prompt_id: 'prompt', timestamp: 1 }]
] as Array<[string, unknown]>)
)
).toBeNull()
})
it('returns detail and message for execution_error entries', () => {
const detail = createExecutionErrorMessage({ exception_message: 'Kaboom' })
const result = jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_success', { prompt_id: 'prompt', timestamp: 2 }],
['execution_error', detail]
] as Array<[string, unknown]>)
)
expect(result).toEqual({
detail,
message: 'Kaboom'
})
})
it('falls back to an empty message when the tuple lacks detail', () => {
const result = jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_error'] as unknown as [string, ExecutionErrorWsMessage]
])
)
expect(result).toEqual({ detail: undefined, message: '' })
})
})
describe('useJobErrorReporting', () => {
let taskState = ref<TaskItemImpl | null>(null)
let taskForJob: ComputedRef<TaskItemImpl | null>
let copyToClipboard: UseJobErrorReportingOptions['copyToClipboard']
let showExecutionErrorDialog: JobErrorDialogService['showExecutionErrorDialog']
let showErrorDialog: JobErrorDialogService['showErrorDialog']
let dialog: JobErrorDialogService
let composable: ReturnType<typeof jobErrorReporting.useJobErrorReporting>
beforeEach(() => {
taskState = ref<TaskItemImpl | null>(null)
taskForJob = computed(() => taskState.value)
copyToClipboard = vi.fn()
showExecutionErrorDialog = vi.fn()
showErrorDialog = vi.fn()
dialog = {
showExecutionErrorDialog,
showErrorDialog
}
composable = jobErrorReporting.useJobErrorReporting({
taskForJob,
copyToClipboard,
dialog
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('exposes a computed message that reflects the current task error', () => {
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'First failure' })
]
])
expect(composable.errorMessageValue.value).toBe('First failure')
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'Second failure' })
]
])
expect(composable.errorMessageValue.value).toBe('Second failure')
})
it('only calls the copy handler when a message exists', () => {
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'Clipboard failure' })
]
])
composable.copyErrorMessage()
expect(copyToClipboard).toHaveBeenCalledTimes(1)
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
vi.mocked(copyToClipboard).mockClear()
taskState.value = createTaskWithMessages([])
composable.copyErrorMessage()
expect(copyToClipboard).not.toHaveBeenCalled()
})
it('prefers the detailed execution dialog when detail is available', () => {
const detail = createExecutionErrorMessage({
exception_message: 'Detailed failure'
})
taskState.value = createTaskWithMessages([['execution_error', detail]])
composable.reportJobError()
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
expect(showExecutionErrorDialog).toHaveBeenCalledWith(detail)
expect(showErrorDialog).not.toHaveBeenCalled()
})
it('shows a fallback dialog when only a message is available', () => {
const message = 'Queue job error'
taskState.value = createTaskWithMessages([])
const valueSpy = vi
.spyOn(composable.errorMessageValue, 'value', 'get')
.mockReturnValue(message)
expect(composable.errorMessageValue.value).toBe(message)
composable.reportJobError()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).toHaveBeenCalledTimes(1)
const [errorArg, optionsArg] = vi.mocked(showErrorDialog).mock.calls[0]
expect(errorArg).toBeInstanceOf(Error)
expect(errorArg.message).toBe(message)
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
valueSpy.mockRestore()
})
it('does nothing when no error could be extracted', () => {
taskState.value = createTaskWithMessages([])
composable.reportJobError()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,100 @@
import { flushPromises, mount } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
vi.mock('@/composables/graph/useSelectionState')
vi.mock('@/stores/workspace/nodeHelpStore')
const baseNode = {
nodePath: 'NodeA',
display_name: 'Node A',
description: '',
inputs: {},
outputs: []
}
describe('NodeHelpPage', () => {
const selection = ref<any | null>(null)
let openHelp: ReturnType<typeof vi.fn>
const mountPage = () =>
mount(NodeHelpPage, {
props: { node: baseNode as any },
global: {
mocks: {
$t: (key: string) => key
},
stubs: {
ProgressSpinner: true,
Button: true
}
}
})
beforeEach(() => {
vi.resetAllMocks()
selection.value = null
openHelp = vi.fn()
vi.mocked(useSelectionState).mockReturnValue({
nodeDef: computed(() => selection.value)
} as any)
vi.mocked(useNodeHelpStore).mockReturnValue({
renderedHelpHtml: ref('<p>help</p>'),
isLoading: ref(false),
error: ref(null),
isHelpOpen: true,
currentHelpNode: { nodePath: 'NodeA' },
openHelp,
closeHelp: vi.fn()
} as any)
})
test('opens help for a newly selected node while help is open', async () => {
const wrapper = mountPage()
selection.value = { nodePath: 'NodeB' }
await flushPromises()
expect(openHelp).toHaveBeenCalledWith({ nodePath: 'NodeB' })
wrapper.unmount()
})
test('does not reopen help when the same node stays selected', async () => {
const wrapper = mountPage()
selection.value = { nodePath: 'NodeA' }
await flushPromises()
expect(openHelp).not.toHaveBeenCalled()
wrapper.unmount()
})
test('does not react to selection when help is closed', async () => {
vi.mocked(useNodeHelpStore).mockReturnValueOnce({
renderedHelpHtml: ref('<p>help</p>'),
isLoading: ref(false),
error: ref(null),
isHelpOpen: false,
currentHelpNode: null,
openHelp,
closeHelp: vi.fn()
} as any)
const wrapper = mountPage()
selection.value = { nodePath: 'NodeB' }
await flushPromises()
expect(openHelp).not.toHaveBeenCalled()
wrapper.unmount()
})
})