mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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>
This commit is contained in:
289
tests-ui/tests/composables/useCompletionSummary.test.ts
Normal file
289
tests-ui/tests/composables/useCompletionSummary.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
type MockTask = {
|
||||
displayStatus: 'Completed' | 'Failed' | 'Cancelled' | 'Running' | 'Pending'
|
||||
executionEndTimestamp?: number
|
||||
previewOutput?: {
|
||||
isImage: boolean
|
||||
urlWithTimestamp: string
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/stores/queueStore', () => {
|
||||
const state = reactive({
|
||||
runningTasks: [] as MockTask[],
|
||||
historyTasks: [] as MockTask[]
|
||||
})
|
||||
|
||||
return {
|
||||
useQueueStore: () => state
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/executionStore', () => {
|
||||
const state = reactive({
|
||||
isIdle: true
|
||||
})
|
||||
|
||||
return {
|
||||
useExecutionStore: () => state
|
||||
}
|
||||
})
|
||||
|
||||
describe('useCompletionSummary', () => {
|
||||
const queueStore = () =>
|
||||
useQueueStore() as {
|
||||
runningTasks: MockTask[]
|
||||
historyTasks: MockTask[]
|
||||
}
|
||||
const executionStore = () => useExecutionStore() as { isIdle: boolean }
|
||||
|
||||
const resetState = () => {
|
||||
queueStore().runningTasks = []
|
||||
queueStore().historyTasks = []
|
||||
executionStore().isIdle = true
|
||||
}
|
||||
|
||||
const createTask = (
|
||||
options: {
|
||||
state?: MockTask['displayStatus']
|
||||
ts?: number
|
||||
previewUrl?: string
|
||||
isImage?: boolean
|
||||
} = {}
|
||||
): MockTask => {
|
||||
const {
|
||||
state = 'Completed',
|
||||
ts = Date.now(),
|
||||
previewUrl,
|
||||
isImage = true
|
||||
} = options
|
||||
|
||||
const task: MockTask = {
|
||||
displayStatus: state,
|
||||
executionEndTimestamp: ts
|
||||
}
|
||||
|
||||
if (previewUrl) {
|
||||
task.previewOutput = {
|
||||
isImage,
|
||||
urlWithTimestamp: previewUrl
|
||||
}
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
const runBatch = async (options: {
|
||||
start: number
|
||||
finish: number
|
||||
tasks: MockTask[]
|
||||
}) => {
|
||||
const { start, finish, tasks } = options
|
||||
|
||||
vi.setSystemTime(start)
|
||||
executionStore().isIdle = false
|
||||
await nextTick()
|
||||
|
||||
vi.setSystemTime(finish)
|
||||
queueStore().historyTasks = tasks
|
||||
executionStore().isIdle = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
resetState()
|
||||
})
|
||||
|
||||
it('summarizes the most recent batch and auto clears after the dismiss delay', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 1_000
|
||||
const finish = 2_000
|
||||
|
||||
const tasks = [
|
||||
createTask({ ts: start - 100, previewUrl: 'ignored-old' }),
|
||||
createTask({ ts: start + 10, previewUrl: 'img-1' }),
|
||||
createTask({ ts: start + 20, previewUrl: 'img-2' }),
|
||||
createTask({ ts: start + 30, previewUrl: 'img-3' }),
|
||||
createTask({ ts: start + 40, previewUrl: 'img-4' }),
|
||||
createTask({ state: 'Failed', ts: start + 50 })
|
||||
]
|
||||
|
||||
await runBatch({ start, finish, tasks })
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'mixed',
|
||||
completedCount: 4,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['img-1', 'img-2', 'img-3']
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(6000)
|
||||
await nextTick()
|
||||
expect(summary.value).toBeNull()
|
||||
})
|
||||
|
||||
it('reports allFailed when every task in the batch failed', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 10_000
|
||||
const finish = 10_200
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [
|
||||
createTask({ state: 'Failed', ts: start + 25 }),
|
||||
createTask({ state: 'Failed', ts: start + 50 })
|
||||
]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 2,
|
||||
thumbnailUrls: []
|
||||
})
|
||||
})
|
||||
|
||||
it('treats cancelled tasks as failures and skips non-image previews', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 15_000
|
||||
const finish = 15_200
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [
|
||||
createTask({ ts: start + 25, previewUrl: 'img-1' }),
|
||||
createTask({
|
||||
state: 'Cancelled',
|
||||
ts: start + 50,
|
||||
previewUrl: 'thumb-ignore',
|
||||
isImage: false
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'mixed',
|
||||
completedCount: 1,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['img-1']
|
||||
})
|
||||
})
|
||||
|
||||
it('clearSummary dismisses the banner immediately and still tracks future batches', async () => {
|
||||
const { summary, clearSummary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
await runBatch({
|
||||
start: 5_000,
|
||||
finish: 5_100,
|
||||
tasks: [createTask({ ts: 5_050, previewUrl: 'img-1' })]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: ['img-1']
|
||||
})
|
||||
|
||||
clearSummary()
|
||||
expect(summary.value).toBeNull()
|
||||
|
||||
await runBatch({
|
||||
start: 6_000,
|
||||
finish: 6_150,
|
||||
tasks: [createTask({ ts: 6_075, previewUrl: 'img-2' })]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: ['img-2']
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores batches that have no finished tasks after the active period started', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 20_000
|
||||
const finish = 20_500
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [createTask({ ts: start - 1, previewUrl: 'too-early' })]
|
||||
})
|
||||
|
||||
expect(summary.value).toBeNull()
|
||||
})
|
||||
|
||||
it('derives the active period from running tasks when execution is already idle', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 25_000
|
||||
vi.setSystemTime(start)
|
||||
queueStore().runningTasks = [
|
||||
createTask({ state: 'Running', ts: start + 1 })
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
const finish = start + 150
|
||||
vi.setSystemTime(finish)
|
||||
queueStore().historyTasks = [
|
||||
createTask({ ts: finish - 10, previewUrl: 'img-running-trigger' })
|
||||
]
|
||||
queueStore().runningTasks = []
|
||||
await nextTick()
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: ['img-running-trigger']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not emit a summary when every finished task is still running or pending', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 30_000
|
||||
const finish = 30_300
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [
|
||||
createTask({ state: 'Running', ts: start + 20 }),
|
||||
createTask({ state: 'Pending', ts: start + 40 })
|
||||
]
|
||||
})
|
||||
|
||||
expect(summary.value).toBeNull()
|
||||
})
|
||||
})
|
||||
520
tests-ui/tests/composables/useJobList.test.ts
Normal file
520
tests-ui/tests/composables/useJobList.test.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
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'
|
||||
])
|
||||
})
|
||||
})
|
||||
728
tests-ui/tests/composables/useJobMenu.test.ts
Normal file
728
tests-ui/tests/composables/useJobMenu.test.ts
Normal file
@@ -0,0 +1,728 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
const downloadFileMock = vi.fn()
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: (...args: any[]) => downloadFileMock(...args)
|
||||
}))
|
||||
|
||||
const copyToClipboardMock = vi.fn()
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({
|
||||
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args)
|
||||
})
|
||||
}))
|
||||
|
||||
const stMock = vi.fn((_: string, fallback?: string) => fallback ?? _)
|
||||
const tMock = vi.fn((key: string) => `i18n:${key}`)
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: (...args: Parameters<typeof stMock>) => stMock(...args),
|
||||
t: (...args: Parameters<typeof tMock>) => tMock(...args)
|
||||
}))
|
||||
|
||||
const mapTaskOutputToAssetItemMock = vi.fn()
|
||||
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
||||
mapTaskOutputToAssetItem: (...args: any[]) =>
|
||||
mapTaskOutputToAssetItemMock(...args)
|
||||
}))
|
||||
|
||||
const mediaAssetActionsMock = {
|
||||
confirmDelete: vi.fn()
|
||||
}
|
||||
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
|
||||
useMediaAssetActions: () => mediaAssetActionsMock
|
||||
}))
|
||||
|
||||
const settingStoreMock = {
|
||||
get: vi.fn()
|
||||
}
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => settingStoreMock
|
||||
}))
|
||||
|
||||
const workflowServiceMock = {
|
||||
openWorkflow: vi.fn()
|
||||
}
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => workflowServiceMock
|
||||
}))
|
||||
|
||||
const workflowStoreMock = {
|
||||
createTemporary: vi.fn()
|
||||
}
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => workflowStoreMock
|
||||
}))
|
||||
|
||||
const interruptMock = vi.fn()
|
||||
const deleteItemMock = vi.fn()
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
interrupt: (...args: any[]) => interruptMock(...args),
|
||||
deleteItem: (...args: any[]) => deleteItemMock(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
const downloadBlobMock = vi.fn()
|
||||
vi.mock('@/scripts/utils', () => ({
|
||||
downloadBlob: (...args: any[]) => downloadBlobMock(...args)
|
||||
}))
|
||||
|
||||
const dialogServiceMock = {
|
||||
showExecutionErrorDialog: vi.fn(),
|
||||
prompt: vi.fn()
|
||||
}
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => dialogServiceMock
|
||||
}))
|
||||
|
||||
const litegraphServiceMock = {
|
||||
addNodeOnGraph: vi.fn(),
|
||||
getCanvasCenter: vi.fn()
|
||||
}
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => litegraphServiceMock
|
||||
}))
|
||||
|
||||
const nodeDefStoreMock = {
|
||||
nodeDefsByName: {} as Record<string, any>
|
||||
}
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => nodeDefStoreMock
|
||||
}))
|
||||
|
||||
const queueStoreMock = {
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
}
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => queueStoreMock
|
||||
}))
|
||||
|
||||
const createAnnotatedPathMock = vi.fn()
|
||||
vi.mock('@/utils/createAnnotatedPath', () => ({
|
||||
createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args)
|
||||
}))
|
||||
|
||||
const appendJsonExtMock = vi.fn((value: string) =>
|
||||
value.toLowerCase().endsWith('.json') ? value : `${value}.json`
|
||||
)
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
appendJsonExt: (...args: Parameters<typeof appendJsonExtMock>) =>
|
||||
appendJsonExtMock(...args)
|
||||
}))
|
||||
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
|
||||
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: overrides.id ?? 'job-1',
|
||||
title: overrides.title ?? 'Test job',
|
||||
meta: overrides.meta ?? 'meta',
|
||||
state: overrides.state ?? 'completed',
|
||||
taskRef: overrides.taskRef,
|
||||
iconName: overrides.iconName,
|
||||
iconImageUrl: overrides.iconImageUrl,
|
||||
showClear: overrides.showClear,
|
||||
progressCurrentPercent: overrides.progressCurrentPercent,
|
||||
progressTotalPercent: overrides.progressTotalPercent,
|
||||
runningNodeName: overrides.runningNodeName,
|
||||
executionTimeMs: overrides.executionTimeMs,
|
||||
computeHours: overrides.computeHours
|
||||
})
|
||||
|
||||
let currentItem: Ref<JobListItem | null>
|
||||
|
||||
const mountJobMenu = (onInspectAsset?: (item: JobListItem) => void) =>
|
||||
useJobMenu(() => currentItem.value, onInspectAsset)
|
||||
|
||||
const findActionEntry = (entries: MenuEntry[], key: string) =>
|
||||
entries.find(
|
||||
(entry): entry is Extract<MenuEntry, { kind?: 'item' }> =>
|
||||
entry.key === key && entry.kind !== 'divider'
|
||||
)
|
||||
|
||||
describe('useJobMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentItem = ref<JobListItem | null>(null)
|
||||
settingStoreMock.get.mockReturnValue(false)
|
||||
dialogServiceMock.prompt.mockResolvedValue(undefined)
|
||||
litegraphServiceMock.getCanvasCenter.mockReturnValue([100, 200])
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValue(null)
|
||||
workflowStoreMock.createTemporary.mockImplementation((filename, data) => ({
|
||||
filename,
|
||||
data
|
||||
}))
|
||||
queueStoreMock.update.mockResolvedValue(undefined)
|
||||
queueStoreMock.delete.mockResolvedValue(undefined)
|
||||
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false)
|
||||
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
|
||||
task,
|
||||
output
|
||||
}))
|
||||
createAnnotatedPathMock.mockReturnValue('annotated-path')
|
||||
nodeDefStoreMock.nodeDefsByName = {
|
||||
LoadImage: { id: 'LoadImage' },
|
||||
LoadVideo: { id: 'LoadVideo' },
|
||||
LoadAudio: { id: 'LoadAudio' }
|
||||
}
|
||||
})
|
||||
|
||||
const setCurrentItem = (item: JobListItem | null) => {
|
||||
currentItem.value = item
|
||||
}
|
||||
|
||||
it('opens workflow when workflow data exists', async () => {
|
||||
const { openJobWorkflow } = mountJobMenu()
|
||||
const workflow = { nodes: [] }
|
||||
setCurrentItem(createJobItem({ id: '55', taskRef: { workflow } }))
|
||||
|
||||
await openJobWorkflow()
|
||||
|
||||
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
|
||||
'Job 55.json',
|
||||
workflow
|
||||
)
|
||||
expect(workflowServiceMock.openWorkflow).toHaveBeenCalledWith({
|
||||
filename: 'Job 55.json',
|
||||
data: workflow
|
||||
})
|
||||
})
|
||||
|
||||
it('does nothing when workflow is missing', async () => {
|
||||
const { openJobWorkflow } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ taskRef: {} }))
|
||||
|
||||
await openJobWorkflow()
|
||||
|
||||
expect(workflowStoreMock.createTemporary).not.toHaveBeenCalled()
|
||||
expect(workflowServiceMock.openWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies job id to clipboard', async () => {
|
||||
const { copyJobId } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ id: 'job-99' }))
|
||||
|
||||
await copyJobId()
|
||||
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith('job-99')
|
||||
})
|
||||
|
||||
it('ignores copy job id when no selection', async () => {
|
||||
const { copyJobId } = mountJobMenu()
|
||||
|
||||
await copyJobId()
|
||||
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['running', interruptMock, deleteItemMock],
|
||||
['initialization', interruptMock, deleteItemMock]
|
||||
])('cancels %s job via interrupt', async (state) => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: state as any }))
|
||||
|
||||
await cancelJob()
|
||||
|
||||
expect(interruptMock).toHaveBeenCalledWith('job-1')
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels pending job via deleteItem', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'pending' }))
|
||||
|
||||
await cancelJob()
|
||||
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still updates queue for uncancellable states', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed' }))
|
||||
|
||||
await cancelJob()
|
||||
|
||||
expect(interruptMock).not.toHaveBeenCalled()
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies error message from failed job entry', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
const error = { exception_message: 'boom' }
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { status: { messages: [['execution_error', error]] } } as any
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith('boom')
|
||||
})
|
||||
|
||||
it('reports error via dialog when entry triggered', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
const error = { exception_message: 'bad', extra: 1 }
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { status: { messages: [['execution_error', error]] } } as any
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith(
|
||||
error
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores error actions when message missing', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
|
||||
|
||||
await nextTick()
|
||||
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
||||
await copyEntry?.onClick?.()
|
||||
const reportEntry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
await reportEntry?.onClick?.()
|
||||
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const previewCases = [
|
||||
{
|
||||
label: 'image',
|
||||
flags: { isImage: true },
|
||||
expectedNode: 'LoadImage',
|
||||
widget: 'image'
|
||||
},
|
||||
{
|
||||
label: 'video',
|
||||
flags: { isVideo: true },
|
||||
expectedNode: 'LoadVideo',
|
||||
widget: 'file'
|
||||
},
|
||||
{
|
||||
label: 'audio',
|
||||
flags: { isAudio: true },
|
||||
expectedNode: 'LoadAudio',
|
||||
widget: 'audio'
|
||||
}
|
||||
] as const
|
||||
|
||||
it.each(previewCases)(
|
||||
'adds loader node for %s preview output',
|
||||
async ({ flags, expectedNode, widget }) => {
|
||||
const widgetCallback = vi.fn()
|
||||
const node = {
|
||||
widgets: [{ name: widget, value: null, callback: widgetCallback }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
const preview = {
|
||||
filename: 'foo.png',
|
||||
subfolder: 'bar',
|
||||
type: 'output',
|
||||
url: 'http://asset',
|
||||
...flags
|
||||
}
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: preview }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalledWith(
|
||||
nodeDefStoreMock.nodeDefsByName[expectedNode],
|
||||
{ pos: [100, 200] }
|
||||
)
|
||||
expect(node.widgets?.[0].value).toBe('annotated-path')
|
||||
expect(widgetCallback).toHaveBeenCalledWith('annotated-path')
|
||||
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
it('skips adding node when no loader definition', async () => {
|
||||
delete nodeDefStoreMock.nodeDefsByName.LoadImage
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips adding node when preview output lacks media flags', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
filename: 'foo',
|
||||
subfolder: 'bar',
|
||||
type: 'output'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips annotating when litegraph node creation fails', async () => {
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(null)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalled()
|
||||
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores add-to-current entry when preview missing entirely', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads preview asset when requested', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: { url: 'https://asset', isImage: true }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
entry?.onClick?.()
|
||||
|
||||
expect(downloadFileMock).toHaveBeenCalledWith('https://asset')
|
||||
})
|
||||
|
||||
it('ignores download request when preview missing', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
entry?.onClick?.()
|
||||
|
||||
expect(downloadFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exports workflow with default filename when prompting disabled', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
id: '7',
|
||||
state: 'completed',
|
||||
taskRef: { workflow: { foo: 'bar' } }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.prompt).not.toHaveBeenCalled()
|
||||
expect(downloadBlobMock).toHaveBeenCalledTimes(1)
|
||||
const [filename, blob] = downloadBlobMock.mock.calls[0]
|
||||
expect(filename).toBe('Job 7.json')
|
||||
await expect(blob.text()).resolves.toBe(
|
||||
JSON.stringify({ foo: 'bar' }, null, 2)
|
||||
)
|
||||
})
|
||||
|
||||
it('prompts for filename when setting enabled', async () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('custom-name')
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { workflow: {} }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.prompt).toHaveBeenCalledWith({
|
||||
title: expect.stringContaining('workflowService.exportWorkflow'),
|
||||
message: expect.stringContaining('workflowService.enterFilename'),
|
||||
defaultValue: 'Job job-1.json'
|
||||
})
|
||||
const [filename] = downloadBlobMock.mock.calls[0]
|
||||
expect(filename).toBe('custom-name.json')
|
||||
})
|
||||
|
||||
it('keeps existing json extension when exporting workflow', async () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('existing.json')
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
id: '42',
|
||||
state: 'completed',
|
||||
taskRef: { workflow: { foo: 'bar' } }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(appendJsonExtMock).toHaveBeenCalledWith('existing.json')
|
||||
const [filename] = downloadBlobMock.mock.calls[0]
|
||||
expect(filename).toBe('existing.json')
|
||||
})
|
||||
|
||||
it('abandons export when prompt cancelled', async () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('')
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { workflow: {} }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes preview asset when confirmed', async () => {
|
||||
mediaAssetActionsMock.confirmDelete.mockResolvedValue(true)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
|
||||
const taskRef = { previewOutput: preview }
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef }))
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(mapTaskOutputToAssetItemMock).toHaveBeenCalledWith(taskRef, preview)
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not refresh queue when delete cancelled', async () => {
|
||||
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes failed job via menu entry', async () => {
|
||||
const taskRef = { id: 'task-1' }
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'failed', taskRef }))
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(queueStoreMock.delete).toHaveBeenCalledWith(taskRef)
|
||||
})
|
||||
|
||||
it('ignores failed job delete when taskRef missing', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'failed' }))
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(queueStoreMock.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('provides completed menu structure with delete option', async () => {
|
||||
const inspectSpy = vi.fn()
|
||||
const { jobMenuEntries } = mountJobMenu(inspectSpy)
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'inspect-asset',
|
||||
'add-to-current',
|
||||
'download',
|
||||
'd1',
|
||||
'open-workflow',
|
||||
'export-workflow',
|
||||
'd2',
|
||||
'copy-id',
|
||||
'd3',
|
||||
'delete'
|
||||
])
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
await inspectEntry?.onClick?.()
|
||||
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
|
||||
})
|
||||
|
||||
it('omits inspect handler when callback missing', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
expect(inspectEntry?.onClick).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits delete asset entry when no preview exists', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
|
||||
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns failed menu entries with error actions', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
|
||||
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'open-workflow',
|
||||
'd1',
|
||||
'copy-id',
|
||||
'copy-error',
|
||||
'report-error',
|
||||
'd2',
|
||||
'delete'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns active job entries with cancel option', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'running' }))
|
||||
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'open-workflow',
|
||||
'd1',
|
||||
'copy-id',
|
||||
'd2',
|
||||
'cancel-job'
|
||||
])
|
||||
})
|
||||
|
||||
it('provides pending job entries and triggers cancel action', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'pending' }))
|
||||
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'open-workflow',
|
||||
'd1',
|
||||
'copy-id',
|
||||
'd2',
|
||||
'cancel-job'
|
||||
])
|
||||
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
|
||||
await cancelEntry?.onClick?.()
|
||||
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty menu when no job selected', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(null)
|
||||
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value).toEqual([])
|
||||
})
|
||||
})
|
||||
160
tests-ui/tests/composables/useQueueProgress.test.ts
Normal file
160
tests-ui/tests/composables/useQueueProgress.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { formatPercent0 } from '@/utils/numberUtil'
|
||||
|
||||
type ProgressValue = number | null
|
||||
|
||||
const localeRef: Ref<string> = ref('en-US') as Ref<string>
|
||||
const executionProgressRef: Ref<ProgressValue> = ref(null)
|
||||
const executingNodeProgressRef: Ref<ProgressValue> = ref(null)
|
||||
|
||||
const createExecutionStoreMock = () => ({
|
||||
get executionProgress() {
|
||||
return executionProgressRef.value ?? undefined
|
||||
},
|
||||
get executingNodeProgress() {
|
||||
return executingNodeProgressRef.value ?? undefined
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
locale: localeRef
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => createExecutionStoreMock()
|
||||
}))
|
||||
|
||||
const mountedWrappers: VueWrapper[] = []
|
||||
|
||||
const mountUseQueueProgress = () => {
|
||||
let composable: ReturnType<typeof useQueueProgress>
|
||||
const wrapper = mount({
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useQueueProgress()
|
||||
return {}
|
||||
}
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
return { wrapper, composable: composable! }
|
||||
}
|
||||
|
||||
const setExecutionProgress = (value?: number | null) => {
|
||||
executionProgressRef.value = value ?? null
|
||||
}
|
||||
|
||||
const setExecutingNodeProgress = (value?: number | null) => {
|
||||
executingNodeProgressRef.value = value ?? null
|
||||
}
|
||||
|
||||
describe('useQueueProgress', () => {
|
||||
beforeEach(() => {
|
||||
localeRef.value = 'en-US'
|
||||
setExecutionProgress(null)
|
||||
setExecutingNodeProgress(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount())
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'defaults to 0% when execution store values are missing',
|
||||
execution: undefined,
|
||||
node: undefined,
|
||||
expectedTotal: 0,
|
||||
expectedNode: 0
|
||||
},
|
||||
{
|
||||
description: 'rounds fractional progress to the nearest integer',
|
||||
execution: 0.324,
|
||||
node: 0.005,
|
||||
expectedTotal: 32,
|
||||
expectedNode: 1
|
||||
},
|
||||
{
|
||||
description: 'clamps values below 0 and above 100%',
|
||||
execution: 1.5,
|
||||
node: -0.25,
|
||||
expectedTotal: 100,
|
||||
expectedNode: 0
|
||||
},
|
||||
{
|
||||
description: 'caps near-complete totals at 100%',
|
||||
execution: 0.999,
|
||||
node: 0.731,
|
||||
expectedTotal: 100,
|
||||
expectedNode: 73
|
||||
}
|
||||
])('$description', ({ execution, node, expectedTotal, expectedNode }) => {
|
||||
setExecutionProgress(execution ?? null)
|
||||
setExecutingNodeProgress(node ?? null)
|
||||
|
||||
const { composable } = mountUseQueueProgress()
|
||||
|
||||
expect(composable.totalPercent.value).toBe(expectedTotal)
|
||||
expect(composable.currentNodePercent.value).toBe(expectedNode)
|
||||
expect(composable.totalPercentFormatted.value).toBe(
|
||||
formatPercent0(localeRef.value, expectedTotal)
|
||||
)
|
||||
expect(composable.currentNodePercentFormatted.value).toBe(
|
||||
formatPercent0(localeRef.value, expectedNode)
|
||||
)
|
||||
})
|
||||
|
||||
it('reformats output when the active locale changes', async () => {
|
||||
setExecutionProgress(0.32)
|
||||
setExecutingNodeProgress(0.58)
|
||||
|
||||
const { composable } = mountUseQueueProgress()
|
||||
|
||||
expect(composable.totalPercentFormatted.value).toBe(
|
||||
formatPercent0('en-US', composable.totalPercent.value)
|
||||
)
|
||||
expect(composable.currentNodePercentFormatted.value).toBe(
|
||||
formatPercent0('en-US', composable.currentNodePercent.value)
|
||||
)
|
||||
|
||||
localeRef.value = 'fr-FR'
|
||||
await nextTick()
|
||||
|
||||
expect(composable.totalPercentFormatted.value).toBe(
|
||||
formatPercent0('fr-FR', composable.totalPercent.value)
|
||||
)
|
||||
expect(composable.currentNodePercentFormatted.value).toBe(
|
||||
formatPercent0('fr-FR', composable.currentNodePercent.value)
|
||||
)
|
||||
})
|
||||
|
||||
it('builds progress bar styles that track store updates', async () => {
|
||||
setExecutionProgress(0.1)
|
||||
setExecutingNodeProgress(0.25)
|
||||
|
||||
const { composable } = mountUseQueueProgress()
|
||||
|
||||
expect(composable.totalProgressStyle.value).toEqual({
|
||||
width: '10%',
|
||||
background: 'var(--color-interface-panel-job-progress-primary)'
|
||||
})
|
||||
expect(composable.currentNodeProgressStyle.value).toEqual({
|
||||
width: '25%',
|
||||
background: 'var(--color-interface-panel-job-progress-secondary)'
|
||||
})
|
||||
|
||||
setExecutionProgress(0.755)
|
||||
setExecutingNodeProgress(0.02)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.totalProgressStyle.value.width).toBe('76%')
|
||||
expect(composable.currentNodeProgressStyle.value.width).toBe('2%')
|
||||
})
|
||||
})
|
||||
103
tests-ui/tests/composables/useResultGallery.test.ts
Normal file
103
tests-ui/tests/composables/useResultGallery.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
type PreviewLike = { url: string; supportsPreview: boolean }
|
||||
|
||||
const createPreview = (url: string, supportsPreview = true): PreviewLike => ({
|
||||
url,
|
||||
supportsPreview
|
||||
})
|
||||
|
||||
const createTask = (preview?: PreviewLike) => ({
|
||||
previewOutput: preview
|
||||
})
|
||||
|
||||
const createJobItem = (id: string, preview?: PreviewLike): JobListItem =>
|
||||
({
|
||||
id,
|
||||
title: `Job ${id}`,
|
||||
meta: '',
|
||||
state: 'completed',
|
||||
showClear: false,
|
||||
taskRef: preview ? { previewOutput: preview } : undefined
|
||||
}) as JobListItem
|
||||
|
||||
describe('useResultGallery', () => {
|
||||
it('collects only previewable outputs and preserves their order', () => {
|
||||
const previewable = [createPreview('p-1'), createPreview('p-2')]
|
||||
const tasks = [
|
||||
createTask(previewable[0]),
|
||||
createTask({ url: 'skip-me', supportsPreview: false }),
|
||||
createTask(previewable[1]),
|
||||
createTask()
|
||||
]
|
||||
|
||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||
() => tasks
|
||||
)
|
||||
|
||||
onViewItem(createJobItem('job-1', previewable[0]))
|
||||
|
||||
expect(galleryItems.value).toEqual(previewable)
|
||||
expect(galleryActiveIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('does not change state when there are no previewable tasks', () => {
|
||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||
() => []
|
||||
)
|
||||
|
||||
onViewItem(createJobItem('job-missing'))
|
||||
|
||||
expect(galleryItems.value).toEqual([])
|
||||
expect(galleryActiveIndex.value).toBe(-1)
|
||||
})
|
||||
|
||||
it('activates the index that matches the viewed preview URL', () => {
|
||||
const previewable = [
|
||||
createPreview('p-1'),
|
||||
createPreview('p-2'),
|
||||
createPreview('p-3')
|
||||
]
|
||||
const tasks = previewable.map((preview) => createTask(preview))
|
||||
|
||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||
() => tasks
|
||||
)
|
||||
|
||||
onViewItem(createJobItem('job-2', createPreview('p-2')))
|
||||
|
||||
expect(galleryItems.value).toEqual(previewable)
|
||||
expect(galleryActiveIndex.value).toBe(1)
|
||||
})
|
||||
|
||||
it('defaults to the first entry when the clicked job lacks a preview', () => {
|
||||
const previewable = [createPreview('p-1'), createPreview('p-2')]
|
||||
const tasks = previewable.map((preview) => createTask(preview))
|
||||
|
||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||
() => tasks
|
||||
)
|
||||
|
||||
onViewItem(createJobItem('job-no-preview'))
|
||||
|
||||
expect(galleryItems.value).toEqual(previewable)
|
||||
expect(galleryActiveIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('defaults to the first entry when no gallery item matches the preview URL', () => {
|
||||
const previewable = [createPreview('p-1'), createPreview('p-2')]
|
||||
const tasks = previewable.map((preview) => createTask(preview))
|
||||
|
||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||
() => tasks
|
||||
)
|
||||
|
||||
onViewItem(createJobItem('job-mismatch', createPreview('missing')))
|
||||
|
||||
expect(galleryItems.value).toEqual(previewable)
|
||||
expect(galleryActiveIndex.value).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user