Files
ComfyUI_frontend/tests-ui/tests/composables/useJobList.test.ts
Benjamin Lu e42715086e Implement workflow progress panel (#6092)
Adds a workflow progress panel component underneath the
`actionbar-container`.

I suggest starting a review at the extraneous changes that were needed.
Including but not limited to:

- `get createTime()` in queueStore
- `promptIdToWorkflowId`, `initializingPromptIds`, and
`nodeProgressStatesByPrompt` in executionStore
- `create_time` handling in v2ToV1Adapter
- `pointer-events-auto` on ComfyActionbar.vue

The rest of the changes should be contained under
`QueueProgressOverlay.vue`, and has less of a blast radius in case
something goes wrong.

---------

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-18 22:43:49 -08:00

521 lines
14 KiB
TypeScript

import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobState } from '@/types/queue'
type TestTask = {
promptId: string
queueIndex: number
mockState: JobState
executionTime?: number
executionEndTimestamp?: number
createTime?: number
workflow?: { id?: string }
}
const translations: Record<string, string> = {
'queue.jobList.undated': 'Undated',
'g.emDash': '--',
'g.untitled': 'Untitled'
}
let localeRef: Ref<string>
let tMock: ReturnType<typeof vi.fn>
const ensureLocaleMocks = () => {
if (!localeRef) {
localeRef = ref('en-US') as Ref<string>
}
if (!tMock) {
tMock = vi.fn((key: string) => translations[key] ?? key)
}
return { localeRef, tMock }
}
vi.mock('vue-i18n', () => ({
useI18n: () => {
ensureLocaleMocks()
return {
t: tMock,
locale: localeRef
}
}
}))
let stMock: ReturnType<typeof vi.fn>
const ensureStMock = () => {
if (!stMock) {
stMock = vi.fn(
(key: string, fallback?: string) => `i18n(${key})-${fallback}`
)
}
return stMock
}
vi.mock('@/i18n', () => ({
st: (...args: any[]) => {
return ensureStMock()(...args)
}
}))
let totalPercent: Ref<number>
let currentNodePercent: Ref<number>
const ensureProgressRefs = () => {
if (!totalPercent) totalPercent = ref(0) as Ref<number>
if (!currentNodePercent) currentNodePercent = ref(0) as Ref<number>
return { totalPercent, currentNodePercent }
}
vi.mock('@/composables/queue/useQueueProgress', () => ({
useQueueProgress: () => {
ensureProgressRefs()
return {
totalPercent,
currentNodePercent
}
}
}))
let buildJobDisplayMock: ReturnType<typeof vi.fn>
const ensureBuildDisplayMock = () => {
if (!buildJobDisplayMock) {
buildJobDisplayMock = vi.fn((task: any, state: JobState, options: any) => ({
primary: `Job ${task.promptId}`,
secondary: `${state} meta`,
iconName: `${state}-icon`,
iconImageUrl: undefined,
showClear: state === 'failed',
options
}))
}
return buildJobDisplayMock
}
vi.mock('@/utils/queueDisplay', () => ({
buildJobDisplay: (...args: any[]) => {
return ensureBuildDisplayMock()(...args)
}
}))
let jobStateFromTaskMock: ReturnType<typeof vi.fn>
const ensureJobStateMock = () => {
if (!jobStateFromTaskMock) {
jobStateFromTaskMock = vi.fn(
(task: TestTask, isInitializing?: boolean): JobState =>
task.mockState ?? (isInitializing ? 'running' : 'completed')
)
}
return jobStateFromTaskMock
}
vi.mock('@/utils/queueUtil', () => ({
jobStateFromTask: (...args: any[]) => {
return ensureJobStateMock()(...args)
}
}))
let queueStoreMock: {
pendingTasks: TestTask[]
runningTasks: TestTask[]
historyTasks: TestTask[]
}
const ensureQueueStore = () => {
if (!queueStoreMock) {
queueStoreMock = reactive({
pendingTasks: [] as TestTask[],
runningTasks: [] as TestTask[],
historyTasks: [] as TestTask[]
})
}
return queueStoreMock
}
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => {
return ensureQueueStore()
}
}))
let executionStoreMock: {
activePromptId: string | null
executingNode: null | { title?: string; type?: string }
isPromptInitializing: (promptId?: string | number) => boolean
}
let isPromptInitializingMock: ReturnType<typeof vi.fn>
const ensureExecutionStore = () => {
if (!isPromptInitializingMock) {
isPromptInitializingMock = vi.fn(() => false)
}
if (!executionStoreMock) {
executionStoreMock = reactive({
activePromptId: null as string | null,
executingNode: null as null | { title?: string; type?: string },
isPromptInitializing: (promptId?: string | number) =>
isPromptInitializingMock(promptId)
})
}
return executionStoreMock
}
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => {
return ensureExecutionStore()
}
}))
let workflowStoreMock: {
activeWorkflow: null | { activeState?: { id?: string } }
}
const ensureWorkflowStore = () => {
if (!workflowStoreMock) {
workflowStoreMock = reactive({
activeWorkflow: null as null | { activeState?: { id?: string } }
})
}
return workflowStoreMock
}
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => {
return ensureWorkflowStore()
}
}))
const createTask = (
overrides: Partial<TestTask> & { mockState?: JobState } = {}
): TestTask => ({
promptId:
overrides.promptId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
queueIndex: overrides.queueIndex ?? 0,
mockState: overrides.mockState ?? 'pending',
executionTime: overrides.executionTime,
executionEndTimestamp: overrides.executionEndTimestamp,
createTime: overrides.createTime,
workflow: overrides.workflow
})
const mountUseJobList = () => {
let composable: ReturnType<typeof useJobList>
const wrapper = mount({
template: '<div />',
setup() {
composable = useJobList()
return {}
}
})
return { wrapper, composable: composable! }
}
const resetStores = () => {
const queueStore = ensureQueueStore()
queueStore.pendingTasks = []
queueStore.runningTasks = []
queueStore.historyTasks = []
const executionStore = ensureExecutionStore()
executionStore.activePromptId = null
executionStore.executingNode = null
const workflowStore = ensureWorkflowStore()
workflowStore.activeWorkflow = null
ensureProgressRefs()
totalPercent.value = 0
currentNodePercent.value = 0
ensureLocaleMocks()
localeRef.value = 'en-US'
tMock.mockClear()
if (stMock) stMock.mockClear()
if (buildJobDisplayMock) buildJobDisplayMock.mockClear()
if (jobStateFromTaskMock) jobStateFromTaskMock.mockClear()
if (isPromptInitializingMock) {
isPromptInitializingMock.mockReset()
isPromptInitializingMock.mockReturnValue(false)
}
}
const flush = async () => {
await nextTick()
}
describe('useJobList', () => {
let wrapper: ReturnType<typeof mount> | null = null
let api: ReturnType<typeof useJobList> | null = null
beforeEach(() => {
resetStores()
wrapper?.unmount()
wrapper = null
api = null
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
api = null
vi.useRealTimers()
})
const initComposable = () => {
const mounted = mountUseJobList()
wrapper = mounted.wrapper
api = mounted.composable
return api!
}
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ promptId: '1', queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
await flush()
jobItems.value
expect(buildJobDisplayMock).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: true })
)
buildJobDisplayMock.mockClear()
await vi.advanceTimersByTimeAsync(3000)
await flush()
jobItems.value
expect(buildJobDisplayMock).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: false })
)
})
it('removes pending hint immediately when the task leaves the queue', async () => {
vi.useFakeTimers()
const taskId = '2'
queueStoreMock.pendingTasks = [
createTask({ promptId: taskId, queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
await flush()
jobItems.value
queueStoreMock.pendingTasks = []
await flush()
expect(vi.getTimerCount()).toBe(0)
buildJobDisplayMock.mockClear()
queueStoreMock.pendingTasks = [
createTask({ promptId: taskId, queueIndex: 2, mockState: 'pending' })
]
await flush()
jobItems.value
expect(buildJobDisplayMock).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: true })
)
})
it('cleans up timeouts on unmount', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ promptId: '3', queueIndex: 1, mockState: 'pending' })
]
initComposable()
await flush()
expect(vi.getTimerCount()).toBeGreaterThan(0)
wrapper?.unmount()
wrapper = null
await flush()
expect(vi.getTimerCount()).toBe(0)
})
it('sorts all tasks by queue index descending', async () => {
queueStoreMock.pendingTasks = [
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
queueStoreMock.runningTasks = [
createTask({ promptId: 'r', queueIndex: 5, mockState: 'running' })
]
queueStoreMock.historyTasks = [
createTask({ promptId: 'h', queueIndex: 3, mockState: 'completed' })
]
const { allTasksSorted } = initComposable()
await flush()
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
'r',
'h',
'p'
])
})
it('filters by job tab and resets failed tab when failures disappear', async () => {
queueStoreMock.historyTasks = [
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ promptId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
const instance = initComposable()
await flush()
instance.selectedJobTab.value = 'Completed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['c'])
instance.selectedJobTab.value = 'Failed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['f'])
expect(instance.hasFailedJobs.value).toBe(true)
queueStoreMock.historyTasks = [
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' })
]
await flush()
expect(instance.hasFailedJobs.value).toBe(false)
expect(instance.selectedJobTab.value).toBe('All')
})
it('filters by active workflow when requested', async () => {
queueStoreMock.pendingTasks = [
createTask({
promptId: 'wf-1',
queueIndex: 2,
mockState: 'pending',
workflow: { id: 'workflow-1' }
}),
createTask({
promptId: 'wf-2',
queueIndex: 1,
mockState: 'pending',
workflow: { id: 'workflow-2' }
})
]
const instance = initComposable()
await flush()
instance.selectedWorkflowFilter.value = 'current'
await flush()
expect(instance.filteredTasks.value).toEqual([])
workflowStoreMock.activeWorkflow = { activeState: { id: 'workflow-1' } }
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual([
'wf-1'
])
})
it('hydrates job items with active progress and compute hours', async () => {
queueStoreMock.runningTasks = [
createTask({
promptId: 'active',
queueIndex: 3,
mockState: 'running',
executionTime: 7_200_000
}),
createTask({
promptId: 'other',
queueIndex: 2,
mockState: 'running',
executionTime: 3_600_000
})
]
executionStoreMock.activePromptId = 'active'
executionStoreMock.executingNode = { title: 'Render Node' }
totalPercent.value = 80
currentNodePercent.value = 40
const { jobItems } = initComposable()
await flush()
const [activeJob, otherJob] = jobItems.value
expect(activeJob.progressTotalPercent).toBe(80)
expect(activeJob.progressCurrentPercent).toBe(40)
expect(activeJob.runningNodeName).toBe('Render Node')
expect(activeJob.computeHours).toBeCloseTo(2)
expect(otherJob.progressTotalPercent).toBeUndefined()
expect(otherJob.progressCurrentPercent).toBeUndefined()
expect(otherJob.runningNodeName).toBeUndefined()
expect(otherJob.computeHours).toBeCloseTo(1)
})
it('derives current node name from execution store fallbacks', async () => {
const instance = initComposable()
await flush()
expect(instance.currentNodeName.value).toBe('--')
executionStoreMock.executingNode = { title: ' Visible Node ' }
await flush()
expect(instance.currentNodeName.value).toBe('Visible Node')
executionStoreMock.executingNode = {
title: ' ',
type: 'My Node Type'
}
await flush()
expect(instance.currentNodeName.value).toBe(
'i18n(nodeDefs.My Node Type.display_name)-My Node Type'
)
})
it('groups job items by date label and sorts by total generation time when requested', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
queueStoreMock.historyTasks = [
createTask({
promptId: 'today-small',
queueIndex: 4,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 2_000
}),
createTask({
promptId: 'today-large',
queueIndex: 3,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 5_000
}),
createTask({
promptId: 'yesterday',
queueIndex: 2,
mockState: 'failed',
executionEndTimestamp: Date.now() - 86_400_000,
executionTime: 1_000
}),
createTask({
promptId: 'undated',
queueIndex: 1,
mockState: 'pending'
})
]
const instance = initComposable()
instance.selectedSortMode.value = 'totalGenerationTime'
await flush()
const groups = instance.groupedJobItems.value
expect(groups.map((g) => g.label)).toEqual([
'Today',
'Yesterday',
'Undated'
])
const todayGroup = groups[0]
expect(todayGroup.items.map((item) => item.id)).toEqual([
'today-large',
'today-small'
])
})
})