mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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>
521 lines
14 KiB
TypeScript
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'
|
|
])
|
|
})
|
|
})
|