mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 05:38:26 +00:00
Compare commits
6 Commits
codex/cove
...
glary/jobs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83789067e9 | ||
|
|
6662bb2a53 | ||
|
|
09f73ec443 | ||
|
|
9558d26e8f | ||
|
|
0615de1f76 | ||
|
|
859b898220 |
188
browser_tests/tests/queue/queueCancelledState.spec.ts
Normal file
188
browser_tests/tests/queue/queueCancelledState.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
create_time: now - 60_000,
|
||||
execution_start_time: now - 60_000,
|
||||
execution_end_time: now - 50_000,
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-failed-1',
|
||||
status: 'failed',
|
||||
create_time: now - 30_000,
|
||||
execution_start_time: now - 30_000,
|
||||
execution_end_time: now - 28_000,
|
||||
outputs_count: 0
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-cancelled-1',
|
||||
status: 'cancelled',
|
||||
create_time: now - 20_000,
|
||||
execution_start_time: now - 20_000,
|
||||
execution_end_time: now - 19_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Queue cancelled state', () => {
|
||||
test.beforeEach(async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Cancelled tab is shown when cancelled jobs exist', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: 'Cancelled', exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancelled tab is distinct from Failed tab', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
|
||||
|
||||
const failedTab = comfyPage.page.getByRole('button', {
|
||||
name: 'Failed',
|
||||
exact: true
|
||||
})
|
||||
const cancelledTab = comfyPage.page.getByRole('button', {
|
||||
name: 'Cancelled',
|
||||
exact: true
|
||||
})
|
||||
|
||||
await expect(failedTab).toBeVisible()
|
||||
await expect(cancelledTab).toBeVisible()
|
||||
})
|
||||
|
||||
test('Failed filter shows only failed jobs (excludes cancelled)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Failed', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-failed-1"]')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-cancelled-1"]')
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-completed-1"]')
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Cancelled filter shows only cancelled jobs (excludes failed)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Cancelled', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-cancelled-1"]')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-failed-1"]')
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-completed-1"]')
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Cancelled job details popover does not show an empty error container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
const cancelledRow = comfyPage.page.locator(
|
||||
'[data-job-id="job-cancelled-1"]'
|
||||
)
|
||||
await expect(cancelledRow).toBeVisible()
|
||||
await cancelledRow.scrollIntoViewIfNeeded()
|
||||
|
||||
const rowBox = await cancelledRow.boundingBox()
|
||||
if (!rowBox) throw new Error('Cancelled job row should be measurable')
|
||||
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await comfyPage.page.mouse.move(
|
||||
rowBox.x + rowBox.width / 2,
|
||||
rowBox.y + rowBox.height / 2,
|
||||
{ steps: 5 }
|
||||
)
|
||||
|
||||
const popover = comfyPage.page.getByTestId(TestIds.queue.jobDetailsPopover)
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect(popover.getByText('Cancelled after')).toBeVisible()
|
||||
await expect(popover.getByText('Failed after')).toBeHidden()
|
||||
await expect(popover.getByText('Error message')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Hides Cancelled tab when no cancelled jobs are present', async ({
|
||||
comfyPage,
|
||||
jobsApi
|
||||
}) => {
|
||||
const completedOnly: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-only-completed',
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 1_000,
|
||||
outputs_count: 1
|
||||
})
|
||||
]
|
||||
await jobsApi.mockJobs(createMockJobRecords(completedOnly))
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-only-completed"]')
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: 'Cancelled', exact: true })
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,8 @@ const defaultProps = {
|
||||
selectedWorkflowFilter: 'all' as const,
|
||||
selectedSortMode: 'mostRecent' as const,
|
||||
displayedJobGroups: [],
|
||||
hasFailedJobs: false
|
||||
hasFailedJobs: false,
|
||||
hasCancelledJobs: false
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
:selected-workflow-filter="selectedWorkflowFilter"
|
||||
:selected-sort-mode="selectedSortMode"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
:has-cancelled-jobs="hasCancelledJobs"
|
||||
@show-assets="$emit('showAssets')"
|
||||
@update:selected-job-tab="onUpdateSelectedJobTab"
|
||||
@update:selected-workflow-filter="
|
||||
@@ -65,6 +66,7 @@ defineProps<{
|
||||
selectedSortMode: JobSortMode
|
||||
displayedJobGroups: JobGroup[]
|
||||
hasFailedJobs: boolean
|
||||
hasCancelledJobs: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
:queued-count="queuedCount"
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
:has-cancelled-jobs="hasCancelledJobs"
|
||||
@show-assets="toggleAssetsSidebar"
|
||||
@clear-history="onClearHistoryFromMenu"
|
||||
@clear-queued="cancelQueuedWorkflows"
|
||||
@@ -182,6 +183,7 @@ const {
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
hasCancelledJobs,
|
||||
filteredTasks,
|
||||
groupedJobItems,
|
||||
currentNodeName
|
||||
|
||||
@@ -327,7 +327,10 @@ function isCancelable(job: JobListItem) {
|
||||
}
|
||||
|
||||
function isFailedDeletable(job: JobListItem) {
|
||||
return job.showClear !== false && job.state === 'failed'
|
||||
return (
|
||||
job.showClear !== false &&
|
||||
(job.state === 'failed' || job.state === 'cancelled')
|
||||
)
|
||||
}
|
||||
|
||||
function getPreviewOutput(job: JobListItem) {
|
||||
|
||||
118
src/components/queue/job/JobDetailsPopover.test.ts
Normal file
118
src/components/queue/job/JobDetailsPopover.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({ copyToClipboard: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showExecutionErrorDialog: vi.fn(),
|
||||
showErrorDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
isJobInitializing: () => false
|
||||
})
|
||||
}))
|
||||
|
||||
function createHistoryTask(
|
||||
id: string,
|
||||
status: JobStatus,
|
||||
executionMs: number
|
||||
): TaskItemImpl {
|
||||
const now = Date.now() / 1000
|
||||
return new TaskItemImpl({
|
||||
id,
|
||||
status,
|
||||
create_time: now - 100,
|
||||
execution_start_time: now - 100,
|
||||
execution_end_time: now - 100 + executionMs / 1000,
|
||||
priority: 1
|
||||
})
|
||||
}
|
||||
|
||||
function renderPopover(task: TaskItemImpl) {
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
})
|
||||
const queueStore = useQueueStore(pinia)
|
||||
queueStore.historyTasks = [task]
|
||||
|
||||
return render(JobDetailsPopover, {
|
||||
props: { jobId: task.jobId, workflowId: 'wf-1' },
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: { Button: { template: '<button><slot /></button>' } },
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('JobDetailsPopover extraRows', () => {
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
})
|
||||
|
||||
it('renders Cancelled after row for a cancelled job and omits the error message section', () => {
|
||||
const task = createHistoryTask('job-cancelled', 'cancelled', 1500)
|
||||
renderPopover(task)
|
||||
|
||||
expect(screen.getByText('Cancelled after')).toBeTruthy()
|
||||
expect(screen.queryByText('Error message')).toBeNull()
|
||||
expect(screen.queryByText('Failed after')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders Failed after row plus the error message section for a failed job', () => {
|
||||
const task = createHistoryTask('job-failed', 'failed', 2500)
|
||||
renderPopover(task)
|
||||
|
||||
expect(screen.getByText('Failed after')).toBeTruthy()
|
||||
expect(screen.getByText('Error message')).toBeTruthy()
|
||||
expect(screen.queryByText('Cancelled after')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders completed-state rows for a completed job and omits failed/cancelled rows', () => {
|
||||
const task = createHistoryTask('job-completed', 'completed', 4200)
|
||||
renderPopover(task)
|
||||
|
||||
expect(screen.getByText('Generated on')).toBeTruthy()
|
||||
expect(screen.getByText('Total generation time')).toBeTruthy()
|
||||
expect(screen.queryByText('Failed after')).toBeNull()
|
||||
expect(screen.queryByText('Cancelled after')).toBeNull()
|
||||
expect(screen.queryByText('Error message')).toBeNull()
|
||||
})
|
||||
|
||||
it('localizes the compute hours value via queue.jobDetails.computeHoursValue for all terminal states', () => {
|
||||
const cases: Array<{ id: string; status: JobStatus }> = [
|
||||
{ id: 'c1', status: 'completed' },
|
||||
{ id: 'f1', status: 'failed' },
|
||||
{ id: 'x1', status: 'cancelled' }
|
||||
]
|
||||
for (const { id, status } of cases) {
|
||||
const task = createHistoryTask(id, status, 1500)
|
||||
const { unmount } = renderPopover(task)
|
||||
expect(screen.getByText(/hours$/)).toBeTruthy()
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -297,7 +297,11 @@ const extraRows = computed<DetailRow[]>(() => {
|
||||
const totalGenTimeValue =
|
||||
execMs !== undefined ? formatElapsedTime(execMs) : ''
|
||||
const computeHoursValue =
|
||||
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
|
||||
execMs !== undefined
|
||||
? t('queue.jobDetails.computeHoursValue', {
|
||||
value: (execMs / 3600000).toFixed(3)
|
||||
})
|
||||
: ''
|
||||
|
||||
const rows: DetailRow[] = [
|
||||
{ label: t('queue.jobDetails.generatedOn'), value: generatedOnValue },
|
||||
@@ -320,7 +324,11 @@ const extraRows = computed<DetailRow[]>(() => {
|
||||
const failedAfterValue =
|
||||
execMs !== undefined ? formatElapsedTime(execMs) : ''
|
||||
const computeHoursValue =
|
||||
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
|
||||
execMs !== undefined
|
||||
? t('queue.jobDetails.computeHoursValue', {
|
||||
value: (execMs / 3600000).toFixed(3)
|
||||
})
|
||||
: ''
|
||||
const rows: DetailRow[] = [
|
||||
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
|
||||
{ label: t('queue.jobDetails.failedAfter'), value: failedAfterValue }
|
||||
@@ -333,6 +341,32 @@ const extraRows = computed<DetailRow[]>(() => {
|
||||
}
|
||||
return rows
|
||||
}
|
||||
if (jobState.value === 'cancelled') {
|
||||
const task = taskForJob.value
|
||||
const execMs: number | undefined = task?.executionTime
|
||||
const cancelledAfterValue =
|
||||
execMs !== undefined ? formatElapsedTime(execMs) : ''
|
||||
const computeHoursValue =
|
||||
execMs !== undefined
|
||||
? t('queue.jobDetails.computeHoursValue', {
|
||||
value: (execMs / 3600000).toFixed(3)
|
||||
})
|
||||
: ''
|
||||
const rows: DetailRow[] = [
|
||||
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
|
||||
{
|
||||
label: t('queue.jobDetails.cancelledAfter'),
|
||||
value: cancelledAfterValue
|
||||
}
|
||||
]
|
||||
if (isCloud) {
|
||||
rows.push({
|
||||
label: t('queue.jobDetails.computeHoursUsed'),
|
||||
value: computeHoursValue
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
|
||||
@@ -22,9 +22,10 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { jobTabs } from '@/composables/queue/useJobList'
|
||||
import type { JobTab } from '@/composables/queue/useJobList'
|
||||
|
||||
const { selectedJobTab, hasFailedJobs } = defineProps<{
|
||||
const { selectedJobTab, hasFailedJobs, hasCancelledJobs } = defineProps<{
|
||||
selectedJobTab: JobTab
|
||||
hasFailedJobs: boolean
|
||||
hasCancelledJobs: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -34,12 +35,17 @@ defineEmits<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const visibleJobTabs = computed(() =>
|
||||
hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
|
||||
jobTabs.filter((tab) => {
|
||||
if (tab === 'Failed') return hasFailedJobs
|
||||
if (tab === 'Cancelled') return hasCancelledJobs
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
const tabLabel = (tab: JobTab) => {
|
||||
if (tab === 'All') return t('g.all')
|
||||
if (tab === 'Completed') return t('g.completed')
|
||||
if (tab === 'Cancelled') return t('g.cancelled')
|
||||
return t('g.failed')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('JobFiltersBar', () => {
|
||||
selectedWorkflowFilter: 'all',
|
||||
selectedSortMode: 'mostRecent',
|
||||
hasFailedJobs: false,
|
||||
hasCancelledJobs: false,
|
||||
onShowAssets: showAssetsSpy
|
||||
},
|
||||
global: {
|
||||
@@ -86,6 +87,7 @@ describe('JobFiltersBar', () => {
|
||||
selectedWorkflowFilter: 'all',
|
||||
selectedSortMode: 'mostRecent',
|
||||
hasFailedJobs: false,
|
||||
hasCancelledJobs: false,
|
||||
hideShowAssetsAction: true
|
||||
},
|
||||
global: {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<JobFilterTabs
|
||||
:selected-job-tab="selectedJobTab"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
:has-cancelled-jobs="hasCancelledJobs"
|
||||
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
|
||||
/>
|
||||
<JobFilterActions
|
||||
@@ -28,12 +29,14 @@ const {
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
hasCancelledJobs,
|
||||
hideShowAssetsAction
|
||||
} = defineProps<{
|
||||
selectedJobTab: JobTab
|
||||
selectedWorkflowFilter: 'all' | 'current'
|
||||
selectedSortMode: JobSortMode
|
||||
hasFailedJobs: boolean
|
||||
hasCancelledJobs: boolean
|
||||
hideShowAssetsAction?: boolean
|
||||
}>()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<JobFilterTabs
|
||||
:selected-job-tab="selectedJobTab"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
:has-cancelled-jobs="hasCancelledJobs"
|
||||
@update:selected-job-tab="onUpdateSelectedJobTab"
|
||||
/>
|
||||
</div>
|
||||
@@ -123,6 +124,7 @@ const {
|
||||
selectedSortMode,
|
||||
searchQuery,
|
||||
hasFailedJobs,
|
||||
hasCancelledJobs,
|
||||
filteredTasks,
|
||||
groupedJobItems
|
||||
} = useJobList()
|
||||
|
||||
@@ -392,6 +392,37 @@ describe('useJobList', () => {
|
||||
expect(instance.selectedJobTab.value).toBe('All')
|
||||
})
|
||||
|
||||
it('exposes cancelled jobs separately from failed jobs and resets tab when they disappear', async () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' }),
|
||||
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' }),
|
||||
createTask({ jobId: 'x', job: { priority: 1 }, mockState: 'cancelled' })
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
await flush()
|
||||
|
||||
expect(instance.hasFailedJobs.value).toBe(true)
|
||||
expect(instance.hasCancelledJobs.value).toBe(true)
|
||||
|
||||
instance.selectedJobTab.value = 'Failed'
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['f'])
|
||||
|
||||
instance.selectedJobTab.value = 'Cancelled'
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['x'])
|
||||
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' }),
|
||||
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' })
|
||||
]
|
||||
await flush()
|
||||
|
||||
expect(instance.hasCancelledJobs.value).toBe(false)
|
||||
expect(instance.selectedJobTab.value).toBe('All')
|
||||
})
|
||||
|
||||
it('filters by active workflow when requested', async () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
|
||||
@@ -24,7 +24,7 @@ import { buildJobDisplay } from '@/utils/queueDisplay'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
/** Tabs for job list filtering */
|
||||
export const jobTabs = ['All', 'Completed', 'Failed'] as const
|
||||
export const jobTabs = ['All', 'Completed', 'Failed', 'Cancelled'] as const
|
||||
export type JobTab = (typeof jobTabs)[number]
|
||||
|
||||
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
|
||||
@@ -223,6 +223,10 @@ export function useJobList() {
|
||||
tasksWithJobState.value.some(({ state }) => state === 'failed')
|
||||
)
|
||||
|
||||
const hasCancelledJobs = computed(() =>
|
||||
tasksWithJobState.value.some(({ state }) => state === 'cancelled')
|
||||
)
|
||||
|
||||
watch(
|
||||
() => hasFailedJobs.value,
|
||||
(hasFailed) => {
|
||||
@@ -232,12 +236,23 @@ export function useJobList() {
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => hasCancelledJobs.value,
|
||||
(hasCancelled) => {
|
||||
if (!hasCancelled && selectedJobTab.value === 'Cancelled') {
|
||||
selectedJobTab.value = 'All'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const filteredTaskEntries = computed<TaskWithState[]>(() => {
|
||||
let entries = tasksWithJobState.value
|
||||
if (selectedJobTab.value === 'Completed') {
|
||||
entries = entries.filter(({ state }) => state === 'completed')
|
||||
} else if (selectedJobTab.value === 'Failed') {
|
||||
entries = entries.filter(({ state }) => state === 'failed')
|
||||
} else if (selectedJobTab.value === 'Cancelled') {
|
||||
entries = entries.filter(({ state }) => state === 'cancelled')
|
||||
}
|
||||
|
||||
if (selectedWorkflowFilter.value === 'current') {
|
||||
@@ -340,7 +355,11 @@ export function useJobList() {
|
||||
const localeValue = locale.value
|
||||
for (const { task, state } of searchableTaskEntries.value) {
|
||||
let ts: number | undefined
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
if (
|
||||
state === 'completed' ||
|
||||
state === 'failed' ||
|
||||
state === 'cancelled'
|
||||
) {
|
||||
ts = task.executionEndTimestamp
|
||||
} else {
|
||||
ts = task.createTime
|
||||
@@ -385,6 +404,7 @@ export function useJobList() {
|
||||
selectedSortMode,
|
||||
searchQuery,
|
||||
hasFailedJobs,
|
||||
hasCancelledJobs,
|
||||
// data sources
|
||||
allTasksSorted,
|
||||
filteredTasks,
|
||||
|
||||
@@ -846,6 +846,25 @@ describe('useJobMenu', () => {
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns cancelled menu entries without error actions or cancel action', async () => {
|
||||
const taskRef = { id: 'task-cancel' }
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'cancelled', taskRef }))
|
||||
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'open-workflow',
|
||||
'd1',
|
||||
'copy-id',
|
||||
'd2',
|
||||
'delete'
|
||||
])
|
||||
|
||||
const deleteEntry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await deleteEntry?.onClick?.()
|
||||
expect(queueStoreMock.delete).toHaveBeenCalledWith(taskRef)
|
||||
})
|
||||
|
||||
it('returns empty menu when no job selected', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(null)
|
||||
|
||||
@@ -342,6 +342,30 @@ export function useJobMenu(
|
||||
}
|
||||
]
|
||||
}
|
||||
if (state === 'cancelled') {
|
||||
return [
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowFailedLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
onClick: removeFailedJob
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: 'open-workflow',
|
||||
|
||||
@@ -1306,7 +1306,9 @@
|
||||
"generatedOn": "Generated on",
|
||||
"totalGenerationTime": "Total generation time",
|
||||
"computeHoursUsed": "Compute hours used",
|
||||
"computeHoursValue": "{value} hours",
|
||||
"failedAfter": "Failed after",
|
||||
"cancelledAfter": "Cancelled after",
|
||||
"errorMessage": "Error message",
|
||||
"report": "Report",
|
||||
"queuePositionValue": "~{count} job ahead of yours | ~{count} jobs ahead of yours",
|
||||
|
||||
@@ -22,7 +22,7 @@ function buildGroupedJobItems(): JobGroup[] {
|
||||
|
||||
const groupedJobItems = computed<JobGroup[]>(buildGroupedJobItems)
|
||||
|
||||
export const jobTabs = ['All', 'Completed', 'Failed'] as const
|
||||
export const jobTabs = ['All', 'Completed', 'Failed', 'Cancelled'] as const
|
||||
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
|
||||
|
||||
const selectedJobTab = ref<JobTab>('All')
|
||||
@@ -41,7 +41,12 @@ function buildHasFailedJobs() {
|
||||
return jobItems.value.some((item) => item.state === 'failed')
|
||||
}
|
||||
|
||||
function buildHasCancelledJobs() {
|
||||
return jobItems.value.some((item) => item.state === 'cancelled')
|
||||
}
|
||||
|
||||
const hasFailedJobs = computed(buildHasFailedJobs)
|
||||
const hasCancelledJobs = computed(buildHasCancelledJobs)
|
||||
|
||||
export function setMockJobItems(items: JobListItem[]) {
|
||||
jobItems.value = items
|
||||
@@ -54,6 +59,7 @@ export function useJobList() {
|
||||
selectedSortMode,
|
||||
searchQuery,
|
||||
hasFailedJobs,
|
||||
hasCancelledJobs,
|
||||
allTasksSorted,
|
||||
filteredTasks,
|
||||
jobItems,
|
||||
|
||||
@@ -7,3 +7,4 @@ export type JobState =
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
|
||||
92
src/utils/queueDisplay.test.ts
Normal file
92
src/utils/queueDisplay.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
|
||||
import type { BuildJobDisplayCtx } from './queueDisplay'
|
||||
import { buildJobDisplay, iconForJobState } from './queueDisplay'
|
||||
|
||||
const noopFormatClockTime = (ts: number) => `t:${ts}`
|
||||
|
||||
const baseCtx = (
|
||||
overrides: Partial<BuildJobDisplayCtx> = {}
|
||||
): BuildJobDisplayCtx => ({
|
||||
t: (key: string) => key,
|
||||
locale: 'en-US',
|
||||
formatClockTimeFn: noopFormatClockTime,
|
||||
isActive: false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const taskFor = (overrides: Partial<TaskItemImpl> = {}): TaskItemImpl =>
|
||||
({
|
||||
jobId: 'job-1',
|
||||
job: { priority: 1 },
|
||||
createTime: 0,
|
||||
...overrides
|
||||
}) as unknown as TaskItemImpl
|
||||
|
||||
describe('iconForJobState', () => {
|
||||
it('returns a distinct icon for cancelled vs failed', () => {
|
||||
expect(iconForJobState('cancelled')).toBe('icon-[lucide--circle-x]')
|
||||
expect(iconForJobState('failed')).toBe('icon-[lucide--circle-alert]')
|
||||
expect(iconForJobState('cancelled')).not.toBe(iconForJobState('failed'))
|
||||
})
|
||||
|
||||
it('returns expected icons for in-progress and completed states', () => {
|
||||
expect(iconForJobState('running')).toBe('icon-[lucide--zap]')
|
||||
expect(iconForJobState('completed')).toBe('icon-[lucide--check-check]')
|
||||
expect(iconForJobState('pending')).toBe('icon-[lucide--loader-circle]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildJobDisplay - cancelled state', () => {
|
||||
it('uses g.cancelled labels (not g.failed) for cancelled jobs', () => {
|
||||
const seen: string[] = []
|
||||
const t = (key: string) => {
|
||||
seen.push(key)
|
||||
return key
|
||||
}
|
||||
|
||||
const display = buildJobDisplay(taskFor(), 'cancelled', baseCtx({ t }))
|
||||
|
||||
expect(display.primary).toBe('g.cancelled')
|
||||
expect(display.secondary).toBe('g.cancelled')
|
||||
expect(seen).toContain('g.cancelled')
|
||||
expect(seen).not.toContain('g.failed')
|
||||
})
|
||||
|
||||
it('uses the cancelled icon, not the failed icon', () => {
|
||||
const display = buildJobDisplay(taskFor(), 'cancelled', baseCtx())
|
||||
expect(display.iconName).toBe(iconForJobState('cancelled'))
|
||||
expect(display.iconName).not.toBe(iconForJobState('failed'))
|
||||
})
|
||||
|
||||
it('keeps the failed branch using g.failed and the failed icon', () => {
|
||||
const display = buildJobDisplay(taskFor(), 'failed', baseCtx())
|
||||
expect(display.primary).toBe('g.failed')
|
||||
expect(display.iconName).toBe(iconForJobState('failed'))
|
||||
})
|
||||
|
||||
it('allows the row to be cleared (showClear true) for cancelled', () => {
|
||||
const display = buildJobDisplay(taskFor(), 'cancelled', baseCtx())
|
||||
expect(display.showClear).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildJobDisplay - exhaustive state handling', () => {
|
||||
const states: JobState[] = [
|
||||
'pending',
|
||||
'initialization',
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
]
|
||||
|
||||
it.each(states)('returns a non-empty display for %s state', (state) => {
|
||||
const display = buildJobDisplay(taskFor(), state, baseCtx())
|
||||
expect(display.iconName).toBeTruthy()
|
||||
expect(display.primary).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,9 @@ export const iconForJobState = (state: JobState): string => {
|
||||
case 'completed':
|
||||
return 'icon-[lucide--check-check]'
|
||||
case 'failed':
|
||||
return 'icon-[lucide--alert-circle]'
|
||||
return 'icon-[lucide--circle-alert]'
|
||||
case 'cancelled':
|
||||
return 'icon-[lucide--circle-x]'
|
||||
default:
|
||||
return 'icon-[lucide--circle]'
|
||||
}
|
||||
@@ -151,6 +153,14 @@ export const buildJobDisplay = (
|
||||
showClear: true
|
||||
}
|
||||
}
|
||||
if (state === 'cancelled') {
|
||||
return {
|
||||
iconName: iconForJobState(state),
|
||||
primary: ctx.t('g.cancelled'),
|
||||
secondary: ctx.t('g.cancelled'),
|
||||
showClear: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
iconName: iconForJobState(state),
|
||||
primary: buildTitle(task, ctx.t),
|
||||
|
||||
64
src/utils/queueUtil.test.ts
Normal file
64
src/utils/queueUtil.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import { isActiveJobState, jobStateFromTask } from './queueUtil'
|
||||
|
||||
type TaskDisplayStatus =
|
||||
| 'Running'
|
||||
| 'Pending'
|
||||
| 'Completed'
|
||||
| 'Failed'
|
||||
| 'Cancelled'
|
||||
|
||||
const taskWithStatus = (displayStatus: TaskDisplayStatus): TaskItemImpl =>
|
||||
({ displayStatus }) as unknown as TaskItemImpl
|
||||
|
||||
describe('jobStateFromTask', () => {
|
||||
it('returns initialization when isInitializing is true regardless of status', () => {
|
||||
expect(jobStateFromTask(taskWithStatus('Running'), true)).toBe(
|
||||
'initialization'
|
||||
)
|
||||
expect(jobStateFromTask(taskWithStatus('Pending'), true)).toBe(
|
||||
'initialization'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps Running to running', () => {
|
||||
expect(jobStateFromTask(taskWithStatus('Running'), false)).toBe('running')
|
||||
})
|
||||
|
||||
it('maps Pending to pending', () => {
|
||||
expect(jobStateFromTask(taskWithStatus('Pending'), false)).toBe('pending')
|
||||
})
|
||||
|
||||
it('maps Completed to completed', () => {
|
||||
expect(jobStateFromTask(taskWithStatus('Completed'), false)).toBe(
|
||||
'completed'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps Failed to failed', () => {
|
||||
expect(jobStateFromTask(taskWithStatus('Failed'), false)).toBe('failed')
|
||||
})
|
||||
|
||||
it('maps Cancelled to cancelled (distinct from failed)', () => {
|
||||
expect(jobStateFromTask(taskWithStatus('Cancelled'), false)).toBe(
|
||||
'cancelled'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isActiveJobState', () => {
|
||||
it('identifies in-progress states as active', () => {
|
||||
expect(isActiveJobState('pending')).toBe(true)
|
||||
expect(isActiveJobState('initialization')).toBe(true)
|
||||
expect(isActiveJobState('running')).toBe(true)
|
||||
})
|
||||
|
||||
it('identifies terminal states as inactive', () => {
|
||||
expect(isActiveJobState('completed')).toBe(false)
|
||||
expect(isActiveJobState('failed')).toBe(false)
|
||||
expect(isActiveJobState('cancelled')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -31,8 +31,9 @@ export const jobStateFromTask = (
|
||||
case 'Completed':
|
||||
return 'completed'
|
||||
case 'Failed':
|
||||
case 'Cancelled':
|
||||
return 'failed'
|
||||
case 'Cancelled':
|
||||
return 'cancelled'
|
||||
}
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user