mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 02:02:08 +00:00
refactor: encapsulate error extraction in TaskItemImpl getters (#7650)
## Summary - Add `errorMessage` and `executionError` getters to `TaskItemImpl` that extract error info from status messages - Update `useJobErrorReporting` composable to use these getters instead of standalone function - Remove the standalone `extractExecutionError` function This encapsulates error extraction within `TaskItemImpl`, preparing for the Jobs API migration where the underlying data format will change but the getter interface will remain stable. ## Test plan - [x] All existing tests pass - [x] New tests added for `TaskItemImpl.errorMessage` and `TaskItemImpl.executionError` getters - [x] TypeScript, lint, and knip checks pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7650-refactor-encapsulate-error-extraction-in-TaskItemImpl-getters-2ce6d73d365081caae33dcc7e1e07720) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
@@ -3,12 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import type {
|
||||
HistoryTaskItem,
|
||||
TaskPrompt,
|
||||
TaskStatus,
|
||||
TaskOutput
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -53,26 +48,25 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
url: string
|
||||
}
|
||||
| undefined
|
||||
public promptId: string
|
||||
|
||||
constructor(
|
||||
public taskType: string,
|
||||
public prompt: TaskPrompt,
|
||||
public status: TaskStatus | undefined,
|
||||
public outputs: TaskOutput
|
||||
) {
|
||||
this.flatOutputs = this.outputs
|
||||
? [
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'test.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'http://test.com/test.png'
|
||||
}
|
||||
]
|
||||
: []
|
||||
constructor(public job: JobListItem) {
|
||||
this.promptId = job.id
|
||||
this.flatOutputs = [
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'test.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'http://test.com/test.png'
|
||||
}
|
||||
]
|
||||
this.previewOutput = this.flatOutputs[0]
|
||||
}
|
||||
|
||||
get previewableOutputs() {
|
||||
return this.flatOutputs.filter((o) => o.supportsPreview)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -82,17 +76,17 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
||||
id: `${type}-${index}`,
|
||||
name,
|
||||
size: 0,
|
||||
created_at: new Date(Date.now() - index * 1000).toISOString(), // Unique timestamps
|
||||
created_at: new Date(Date.now() - index * 1000).toISOString(),
|
||||
tags: [type],
|
||||
preview_url: `http://test.com/${name}`
|
||||
})),
|
||||
mapTaskOutputToAssetItem: vi.fn((task, output) => {
|
||||
const index = parseInt(task.prompt[1].split('_')[1]) || 0
|
||||
const index = parseInt(task.promptId.split('_')[1]) || 0
|
||||
return {
|
||||
id: task.prompt[1], // Use promptId as asset ID
|
||||
id: task.promptId,
|
||||
name: output.filename,
|
||||
size: 0,
|
||||
created_at: new Date(Date.now() - index * 1000).toISOString(), // Unique timestamps
|
||||
created_at: new Date(Date.now() - index * 1000).toISOString(),
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
user_metadata: {}
|
||||
@@ -103,43 +97,20 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
||||
describe('assetsStore - Refactored (Option A)', () => {
|
||||
let store: ReturnType<typeof useAssetsStore>
|
||||
|
||||
// Helper function to create mock history items
|
||||
const createMockHistoryItem = (index: number): HistoryTaskItem => ({
|
||||
taskType: 'History' as const,
|
||||
prompt: [
|
||||
1000 + index, // queueIndex
|
||||
`prompt_${index}`, // promptId
|
||||
{}, // promptInputs
|
||||
{
|
||||
extra_pnginfo: {
|
||||
workflow: {
|
||||
last_node_id: 1,
|
||||
last_link_id: 1,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
version: 1
|
||||
}
|
||||
}
|
||||
}, // extraData
|
||||
[] // outputsToExecute
|
||||
],
|
||||
status: {
|
||||
status_str: 'success' as const,
|
||||
completed: true,
|
||||
messages: []
|
||||
},
|
||||
outputs: {
|
||||
'1': {
|
||||
images: [
|
||||
{
|
||||
filename: `output_${index}.png`,
|
||||
subfolder: '',
|
||||
type: 'output' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
// Helper function to create mock job items
|
||||
const createMockJobItem = (index: number): JobListItem => ({
|
||||
id: `prompt_${index}`,
|
||||
status: 'completed',
|
||||
create_time: 1000 + index,
|
||||
update_time: 1000 + index,
|
||||
last_state_update: 1000 + index,
|
||||
priority: 1000 + index,
|
||||
preview_output: {
|
||||
filename: `output_${index}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node_1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -152,11 +123,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
describe('Initial Load', () => {
|
||||
it('should load initial history items', async () => {
|
||||
const mockHistory = Array.from({ length: 10 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValue({
|
||||
History: mockHistory
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
@@ -169,11 +138,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
|
||||
it('should set hasMoreHistory to true when batch is full', async () => {
|
||||
const mockHistory = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValue({
|
||||
History: mockHistory
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
@@ -197,11 +164,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
it('should accumulate items when loading more', async () => {
|
||||
// First batch - full BATCH_SIZE
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||
|
||||
await store.updateHistory()
|
||||
expect(store.historyAssets).toHaveLength(200)
|
||||
@@ -209,11 +174,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
|
||||
// Second batch - different items
|
||||
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(200 + i)
|
||||
createMockJobItem(200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: secondBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(secondBatch)
|
||||
|
||||
await store.loadMoreHistory()
|
||||
|
||||
@@ -225,24 +188,20 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
it('should prevent duplicate items during pagination', async () => {
|
||||
// First batch - full BATCH_SIZE
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||
|
||||
await store.updateHistory()
|
||||
expect(store.historyAssets).toHaveLength(200)
|
||||
|
||||
// Second batch with some duplicates
|
||||
const secondBatch = [
|
||||
createMockHistoryItem(2), // Duplicate
|
||||
createMockHistoryItem(5), // Duplicate
|
||||
...Array.from({ length: 198 }, (_, i) => createMockHistoryItem(200 + i)) // New
|
||||
createMockJobItem(2), // Duplicate
|
||||
createMockJobItem(5), // Duplicate
|
||||
...Array.from({ length: 198 }, (_, i) => createMockJobItem(200 + i)) // New
|
||||
]
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: secondBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(secondBatch)
|
||||
|
||||
await store.loadMoreHistory()
|
||||
|
||||
@@ -258,11 +217,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
it('should stop loading when no more items', async () => {
|
||||
// First batch - less than BATCH_SIZE
|
||||
const firstBatch = Array.from({ length: 50 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||
|
||||
await store.updateHistory()
|
||||
expect(store.hasMoreHistory).toBe(false)
|
||||
@@ -277,11 +234,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
it('should handle race conditions with concurrent loads', async () => {
|
||||
// Setup initial state with full batch
|
||||
const initialBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: initialBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(initialBatch)
|
||||
await store.updateHistory()
|
||||
expect(store.hasMoreHistory).toBe(true)
|
||||
|
||||
@@ -289,12 +244,10 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
vi.mocked(api.getHistory).mockClear()
|
||||
|
||||
// Setup slow API response
|
||||
let resolveLoadMore: (value: { History: HistoryTaskItem[] }) => void
|
||||
const loadMorePromise = new Promise<{ History: HistoryTaskItem[] }>(
|
||||
(resolve) => {
|
||||
resolveLoadMore = resolve
|
||||
}
|
||||
)
|
||||
let resolveLoadMore: (value: JobListItem[]) => void
|
||||
const loadMorePromise = new Promise<JobListItem[]>((resolve) => {
|
||||
resolveLoadMore = resolve
|
||||
})
|
||||
vi.mocked(api.getHistory).mockReturnValueOnce(loadMorePromise)
|
||||
|
||||
// Start first loadMore
|
||||
@@ -305,9 +258,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
|
||||
// Resolve
|
||||
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(200 + i)
|
||||
createMockJobItem(200 + i)
|
||||
)
|
||||
resolveLoadMore!({ History: secondBatch })
|
||||
resolveLoadMore!(secondBatch)
|
||||
|
||||
await Promise.all([firstLoad, secondLoad])
|
||||
|
||||
@@ -320,21 +273,17 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
|
||||
// Initial load
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||
await store.updateHistory()
|
||||
|
||||
// Load additional batches
|
||||
for (let batch = 1; batch < BATCH_COUNT; batch++) {
|
||||
const items = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(batch * 200 + i)
|
||||
createMockJobItem(batch * 200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: items
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(items)
|
||||
await store.loadMoreHistory()
|
||||
}
|
||||
|
||||
@@ -347,21 +296,17 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
it('should maintain date sorting after pagination', async () => {
|
||||
// First batch
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
// Second batch
|
||||
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(200 + i)
|
||||
createMockJobItem(200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: secondBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(secondBatch)
|
||||
|
||||
await store.loadMoreHistory()
|
||||
|
||||
@@ -378,11 +323,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
it('should preserve existing data when loadMore fails', async () => {
|
||||
// First successful load - full batch
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||
|
||||
await store.updateHistory()
|
||||
expect(store.historyAssets).toHaveLength(200)
|
||||
@@ -402,11 +345,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
it('should clear error state on successful retry', async () => {
|
||||
// First load succeeds
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
@@ -419,11 +360,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
|
||||
// Third load succeeds
|
||||
const thirdBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(200 + i)
|
||||
createMockJobItem(200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: thirdBatch
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(thirdBatch)
|
||||
|
||||
await store.loadMoreHistory()
|
||||
|
||||
@@ -450,11 +389,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
|
||||
for (let batch = 0; batch < batches; batch++) {
|
||||
const items = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(batch * 200 + i)
|
||||
createMockJobItem(batch * 200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: items
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(items)
|
||||
|
||||
if (batch === 0) {
|
||||
await store.updateHistory()
|
||||
@@ -476,11 +413,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
// Load items beyond limit
|
||||
for (let batch = 0; batch < 6; batch++) {
|
||||
const items = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(batch * 200 + i)
|
||||
createMockJobItem(batch * 200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: items
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce(items)
|
||||
|
||||
if (batch === 0) {
|
||||
await store.updateHistory()
|
||||
@@ -503,11 +438,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
describe('jobDetailView Support', () => {
|
||||
it('should include outputCount and allOutputs in user_metadata', async () => {
|
||||
const mockHistory = Array.from({ length: 5 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
createMockJobItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValue({
|
||||
History: mockHistory
|
||||
})
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { TaskItem } from '@/schemas/apiSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { TaskItemImpl } from './queueStore'
|
||||
@@ -48,27 +48,18 @@ async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert history task items to asset items
|
||||
* Convert history job items to asset items
|
||||
*/
|
||||
function mapHistoryToAssets(historyItems: TaskItem[]): AssetItem[] {
|
||||
function mapHistoryToAssets(historyItems: JobListItem[]): AssetItem[] {
|
||||
const assetItems: AssetItem[] = []
|
||||
|
||||
for (const item of historyItems) {
|
||||
// Type guard for HistoryTaskItem which has status and outputs
|
||||
if (item.taskType !== 'History') {
|
||||
for (const job of historyItems) {
|
||||
// Only process completed jobs with preview output
|
||||
if (job.status !== 'completed' || !job.preview_output) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!item.outputs || !item.status || item.status?.status_str === 'error') {
|
||||
continue
|
||||
}
|
||||
|
||||
const task = new TaskItemImpl(
|
||||
'History',
|
||||
item.prompt,
|
||||
item.status,
|
||||
item.outputs
|
||||
)
|
||||
const task = new TaskItemImpl(job)
|
||||
|
||||
if (!task.previewOutput) {
|
||||
continue
|
||||
@@ -76,11 +67,10 @@ function mapHistoryToAssets(historyItems: TaskItem[]): AssetItem[] {
|
||||
|
||||
const assetItem = mapTaskOutputToAssetItem(task, task.previewOutput)
|
||||
|
||||
const supportedOutputs = task.flatOutputs.filter((o) => o.supportsPreview)
|
||||
assetItem.user_metadata = {
|
||||
...assetItem.user_metadata,
|
||||
outputCount: supportedOutputs.length,
|
||||
allOutputs: supportedOutputs
|
||||
outputCount: job.outputs_count,
|
||||
allOutputs: task.previewableOutputs
|
||||
}
|
||||
|
||||
assetItems.push(assetItem)
|
||||
@@ -143,8 +133,8 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
offset: historyOffset.value
|
||||
})
|
||||
|
||||
// Convert TaskItems to AssetItems
|
||||
const newAssets = mapHistoryToAssets(history.History)
|
||||
// Convert JobListItems to AssetItems
|
||||
const newAssets = mapHistoryToAssets(history)
|
||||
|
||||
if (loadMore) {
|
||||
// Filter out duplicates and insert in sorted order
|
||||
@@ -176,7 +166,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
// Update pagination state
|
||||
historyOffset.value += BATCH_SIZE
|
||||
hasMoreHistory.value = history.History.length === BATCH_SIZE
|
||||
hasMoreHistory.value = history.length === BATCH_SIZE
|
||||
|
||||
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
|
||||
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
||||
|
||||
@@ -396,10 +396,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
error: e.detail.exception_message
|
||||
})
|
||||
}
|
||||
const pid = e.detail?.prompt_id
|
||||
// Clear initialization for errored prompt if present
|
||||
if (e.detail?.prompt_id) clearInitializationByPromptId(e.detail.prompt_id)
|
||||
resetExecutionState(pid)
|
||||
clearInitializationByPromptId(e.detail.prompt_id)
|
||||
resetExecutionState(e.detail.prompt_id)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import type {
|
||||
JobDetail,
|
||||
JobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { TaskItemImpl } from '@/stores/queueStore'
|
||||
import * as getWorkflowModule from '@/platform/workflow/cloud'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
import * as jobOutputCache from '@/services/jobOutputCache'
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: vi.fn(() => ({
|
||||
@@ -17,8 +17,6 @@ vi.mock('@/services/extensionService', () => ({
|
||||
}))
|
||||
|
||||
const mockWorkflow: ComfyWorkflowJSON = {
|
||||
id: 'test-workflow-id',
|
||||
revision: 0,
|
||||
last_node_id: 5,
|
||||
last_link_id: 3,
|
||||
nodes: [],
|
||||
@@ -29,53 +27,46 @@ const mockWorkflow: ComfyWorkflowJSON = {
|
||||
version: 0.4
|
||||
}
|
||||
|
||||
const createHistoryTaskWithWorkflow = (): TaskItemImpl => {
|
||||
return new TaskItemImpl(
|
||||
'History',
|
||||
[
|
||||
0, // queueIndex
|
||||
'test-prompt-id', // promptId
|
||||
{}, // promptInputs
|
||||
{
|
||||
client_id: 'test-client',
|
||||
extra_pnginfo: {
|
||||
workflow: mockWorkflow
|
||||
}
|
||||
},
|
||||
[] // outputsToExecute
|
||||
],
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
},
|
||||
{} // outputs
|
||||
)
|
||||
// Mock job detail response (matches actual /jobs/{id} API response structure)
|
||||
// workflow is nested at: workflow.extra_data.extra_pnginfo.workflow
|
||||
const mockJobDetail = {
|
||||
id: 'test-prompt-id',
|
||||
status: 'completed' as const,
|
||||
create_time: Date.now(),
|
||||
update_time: Date.now(),
|
||||
workflow: {
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: mockWorkflow
|
||||
}
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
'1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] }
|
||||
}
|
||||
}
|
||||
|
||||
const createHistoryTaskWithoutWorkflow = (): TaskItemImpl => {
|
||||
return new TaskItemImpl(
|
||||
'History',
|
||||
[
|
||||
0,
|
||||
'test-prompt-id',
|
||||
{},
|
||||
{
|
||||
client_id: 'test-client'
|
||||
// No extra_pnginfo.workflow
|
||||
},
|
||||
[]
|
||||
],
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
},
|
||||
{}
|
||||
)
|
||||
function createHistoryJob(id: string): JobListItem {
|
||||
const now = Date.now()
|
||||
return {
|
||||
id,
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
priority: now
|
||||
}
|
||||
}
|
||||
|
||||
describe('TaskItemImpl.loadWorkflow - cloud history workflow fetching', () => {
|
||||
function createRunningJob(id: string): JobListItem {
|
||||
const now = Date.now()
|
||||
return {
|
||||
id,
|
||||
status: 'in_progress',
|
||||
create_time: now,
|
||||
priority: now
|
||||
}
|
||||
}
|
||||
|
||||
describe('TaskItemImpl.loadWorkflow - workflow fetching', () => {
|
||||
let mockApp: ComfyApp
|
||||
let mockFetchApi: ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -91,85 +82,57 @@ describe('TaskItemImpl.loadWorkflow - cloud history workflow fetching', () => {
|
||||
fetchApi: mockFetchApi
|
||||
}
|
||||
} as unknown as ComfyApp
|
||||
|
||||
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory')
|
||||
})
|
||||
|
||||
it('should load workflow directly when workflow is in extra_pnginfo', async () => {
|
||||
const task = createHistoryTaskWithWorkflow()
|
||||
it('should fetch workflow from API for history tasks', async () => {
|
||||
const job = createHistoryJob('test-prompt-id')
|
||||
const task = new TaskItemImpl(job)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fetch workflow from cloud when workflow is missing from history task', async () => {
|
||||
const task = createHistoryTaskWithoutWorkflow()
|
||||
|
||||
// Mock getWorkflowFromHistory to return workflow
|
||||
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
|
||||
mockWorkflow
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
mockJobDetail as JobDetail
|
||||
)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'test-prompt-id'
|
||||
)
|
||||
expect(jobOutputCache.getJobDetail).toHaveBeenCalledWith('test-prompt-id')
|
||||
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
||||
})
|
||||
|
||||
it('should not load workflow when fetch returns undefined', async () => {
|
||||
const task = createHistoryTaskWithoutWorkflow()
|
||||
const job = createHistoryJob('test-prompt-id')
|
||||
const task = new TaskItemImpl(job)
|
||||
|
||||
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
|
||||
undefined
|
||||
)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(undefined)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled()
|
||||
expect(jobOutputCache.getJobDetail).toHaveBeenCalled()
|
||||
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only fetch for history tasks, not running tasks', async () => {
|
||||
const runningTask = new TaskItemImpl(
|
||||
'Running',
|
||||
[
|
||||
0,
|
||||
'test-prompt-id',
|
||||
{},
|
||||
{
|
||||
client_id: 'test-client'
|
||||
},
|
||||
[]
|
||||
],
|
||||
undefined,
|
||||
{}
|
||||
)
|
||||
const job = createRunningJob('test-prompt-id')
|
||||
const runningTask = new TaskItemImpl(job)
|
||||
|
||||
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
|
||||
mockWorkflow
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||
mockJobDetail as JobDetail
|
||||
)
|
||||
|
||||
await runningTask.loadWorkflow(mockApp)
|
||||
|
||||
expect(getWorkflowModule.getWorkflowFromHistory).not.toHaveBeenCalled()
|
||||
expect(jobOutputCache.getJobDetail).not.toHaveBeenCalled()
|
||||
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle fetch errors gracefully by returning undefined', async () => {
|
||||
const task = createHistoryTaskWithoutWorkflow()
|
||||
const job = createHistoryJob('test-prompt-id')
|
||||
const task = new TaskItemImpl(job)
|
||||
|
||||
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
|
||||
undefined
|
||||
)
|
||||
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(undefined)
|
||||
|
||||
await task.loadWorkflow(mockApp)
|
||||
|
||||
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled()
|
||||
expect(jobOutputCache.getJobDetail).toHaveBeenCalled()
|
||||
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,34 +1,39 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
HistoryTaskItem,
|
||||
PendingTaskItem,
|
||||
RunningTaskItem,
|
||||
TaskOutput,
|
||||
TaskPrompt,
|
||||
TaskStatus
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { TaskOutput } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
// Fixture factories
|
||||
const createTaskPrompt = (
|
||||
queueIndex: number,
|
||||
promptId: string,
|
||||
inputs: Record<string, any> = {},
|
||||
extraData: Record<string, any> = {},
|
||||
outputsToExecute: any[] = []
|
||||
): TaskPrompt => [queueIndex, promptId, inputs, extraData, outputsToExecute]
|
||||
// Fixture factory for JobListItem
|
||||
function createJob(
|
||||
id: string,
|
||||
status: JobListItem['status'],
|
||||
createTime: number = Date.now(),
|
||||
priority?: number
|
||||
): JobListItem {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
create_time: createTime,
|
||||
update_time: createTime,
|
||||
last_state_update: createTime,
|
||||
priority: priority ?? createTime
|
||||
}
|
||||
}
|
||||
|
||||
const createTaskStatus = (
|
||||
statusStr: 'success' | 'error' = 'success',
|
||||
messages: any[] = []
|
||||
): TaskStatus => ({
|
||||
status_str: statusStr,
|
||||
completed: true,
|
||||
messages
|
||||
})
|
||||
function createRunningJob(createTime: number, id: string): JobListItem {
|
||||
return createJob(id, 'in_progress', createTime)
|
||||
}
|
||||
|
||||
function createPendingJob(createTime: number, id: string): JobListItem {
|
||||
return createJob(id, 'pending', createTime)
|
||||
}
|
||||
|
||||
function createHistoryJob(createTime: number, id: string): JobListItem {
|
||||
return createJob(id, 'completed', createTime)
|
||||
}
|
||||
|
||||
const createTaskOutput = (
|
||||
nodeId: string = 'node-1',
|
||||
@@ -39,35 +44,6 @@ const createTaskOutput = (
|
||||
}
|
||||
})
|
||||
|
||||
const createRunningTask = (
|
||||
queueIndex: number,
|
||||
promptId: string
|
||||
): RunningTaskItem => ({
|
||||
taskType: 'Running',
|
||||
prompt: createTaskPrompt(queueIndex, promptId),
|
||||
remove: { name: 'Cancel', cb: () => {} }
|
||||
})
|
||||
|
||||
const createPendingTask = (
|
||||
queueIndex: number,
|
||||
promptId: string
|
||||
): PendingTaskItem => ({
|
||||
taskType: 'Pending',
|
||||
prompt: createTaskPrompt(queueIndex, promptId)
|
||||
})
|
||||
|
||||
const createHistoryTask = (
|
||||
queueIndex: number,
|
||||
promptId: string,
|
||||
outputs: TaskOutput = createTaskOutput(),
|
||||
status: TaskStatus = createTaskStatus()
|
||||
): HistoryTaskItem => ({
|
||||
taskType: 'History',
|
||||
prompt: createTaskPrompt(queueIndex, promptId),
|
||||
status,
|
||||
outputs
|
||||
})
|
||||
|
||||
// Mock API
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -83,17 +59,13 @@ vi.mock('@/scripts/api', () => ({
|
||||
|
||||
describe('TaskItemImpl', () => {
|
||||
it('should remove animated property from outputs during construction', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
images: [{ filename: 'test.png', type: 'output', subfolder: '' }],
|
||||
animated: [false]
|
||||
}
|
||||
const job = createHistoryJob(0, 'prompt-id')
|
||||
const taskItem = new TaskItemImpl(job, {
|
||||
'node-1': {
|
||||
images: [{ filename: 'test.png', type: 'output', subfolder: '' }],
|
||||
animated: [false]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Check that animated property was removed
|
||||
expect('animated' in taskItem.outputs['node-1']).toBe(false)
|
||||
@@ -103,90 +75,72 @@ describe('TaskItemImpl', () => {
|
||||
})
|
||||
|
||||
it('should handle outputs without animated property', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
images: [{ filename: 'test.png', type: 'output', subfolder: '' }]
|
||||
}
|
||||
const job = createHistoryJob(0, 'prompt-id')
|
||||
const taskItem = new TaskItemImpl(job, {
|
||||
'node-1': {
|
||||
images: [{ filename: 'test.png', type: 'output', subfolder: '' }]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
expect(taskItem.outputs['node-1'].images).toBeDefined()
|
||||
expect(taskItem.outputs['node-1'].images?.[0]?.filename).toBe('test.png')
|
||||
})
|
||||
|
||||
it('should recognize webm video from core', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
video: [{ filename: 'test.webm', type: 'output', subfolder: '' }]
|
||||
}
|
||||
const job = createHistoryJob(0, 'prompt-id')
|
||||
const taskItem = new TaskItemImpl(job, {
|
||||
'node-1': {
|
||||
video: [{ filename: 'test.webm', type: 'output', subfolder: '' }]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const output = taskItem.flatOutputs[0]
|
||||
|
||||
expect(output.htmlVideoType).toBe('video/webm')
|
||||
expect(output.isVideo).toBe(true)
|
||||
expect(output.isWebm).toBe(true)
|
||||
expect(output.isVhsFormat).toBe(false)
|
||||
expect(output.isImage).toBe(false)
|
||||
})
|
||||
|
||||
// https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/blob/0a75c7958fe320efcb052f1d9f8451fd20c730a8/videohelpersuite/nodes.py#L578-L590
|
||||
it('should recognize webm video from VHS', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
gifs: [
|
||||
{
|
||||
filename: 'test.webm',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
format: 'video/webm',
|
||||
frame_rate: 30
|
||||
}
|
||||
]
|
||||
}
|
||||
const job = createHistoryJob(0, 'prompt-id')
|
||||
const taskItem = new TaskItemImpl(job, {
|
||||
'node-1': {
|
||||
gifs: [
|
||||
{
|
||||
filename: 'test.webm',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
format: 'video/webm',
|
||||
frame_rate: 30
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const output = taskItem.flatOutputs[0]
|
||||
|
||||
expect(output.htmlVideoType).toBe('video/webm')
|
||||
expect(output.isVideo).toBe(true)
|
||||
expect(output.isWebm).toBe(true)
|
||||
expect(output.isVhsFormat).toBe(true)
|
||||
expect(output.isImage).toBe(false)
|
||||
})
|
||||
|
||||
it('should recognize mp4 video from core', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
images: [
|
||||
{
|
||||
filename: 'test.mp4',
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
}
|
||||
],
|
||||
animated: [true]
|
||||
}
|
||||
const job = createHistoryJob(0, 'prompt-id')
|
||||
const taskItem = new TaskItemImpl(job, {
|
||||
'node-1': {
|
||||
images: [
|
||||
{
|
||||
filename: 'test.mp4',
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
}
|
||||
],
|
||||
animated: [true]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const output = taskItem.flatOutputs[0]
|
||||
|
||||
@@ -205,22 +159,18 @@ describe('TaskItemImpl', () => {
|
||||
|
||||
audioFormats.forEach(({ extension, mimeType }) => {
|
||||
it(`should recognize ${extension} audio`, () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
audio: [
|
||||
{
|
||||
filename: `test.${extension}`,
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
const job = createHistoryJob(0, 'prompt-id')
|
||||
const taskItem = new TaskItemImpl(job, {
|
||||
'node-1': {
|
||||
audio: [
|
||||
{
|
||||
filename: `test.${extension}`,
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const output = taskItem.flatOutputs[0]
|
||||
|
||||
@@ -232,6 +182,58 @@ describe('TaskItemImpl', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('error extraction getters', () => {
|
||||
it('errorMessage returns undefined when no execution_error', () => {
|
||||
const job = createHistoryJob(0, 'prompt-id')
|
||||
const taskItem = new TaskItemImpl(job)
|
||||
expect(taskItem.errorMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('errorMessage returns the exception_message from execution_error', () => {
|
||||
const job: JobListItem = {
|
||||
...createHistoryJob(0, 'prompt-id'),
|
||||
status: 'failed',
|
||||
execution_error: {
|
||||
node_id: 'node-1',
|
||||
node_type: 'KSampler',
|
||||
exception_message: 'GPU out of memory',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: ['line 1', 'line 2'],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
}
|
||||
const taskItem = new TaskItemImpl(job)
|
||||
expect(taskItem.errorMessage).toBe('GPU out of memory')
|
||||
})
|
||||
|
||||
it('executionError returns undefined when no execution_error', () => {
|
||||
const job = createHistoryJob(0, 'prompt-id')
|
||||
const taskItem = new TaskItemImpl(job)
|
||||
expect(taskItem.executionError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('executionError returns the full error object from execution_error', () => {
|
||||
const errorDetail = {
|
||||
node_id: 'node-1',
|
||||
node_type: 'KSampler',
|
||||
executed: ['node-0'],
|
||||
exception_message: 'Invalid dimensions',
|
||||
exception_type: 'ValueError',
|
||||
traceback: ['traceback line'],
|
||||
current_inputs: { input1: 'value' },
|
||||
current_outputs: {}
|
||||
}
|
||||
const job: JobListItem = {
|
||||
...createHistoryJob(0, 'prompt-id'),
|
||||
status: 'failed',
|
||||
execution_error: errorDetail
|
||||
}
|
||||
const taskItem = new TaskItemImpl(job)
|
||||
expect(taskItem.executionError).toEqual(errorDetail)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useQueueStore', () => {
|
||||
@@ -267,15 +269,16 @@ describe('useQueueStore', () => {
|
||||
|
||||
describe('update() - basic functionality', () => {
|
||||
it('should load running and pending tasks from API', async () => {
|
||||
const runningTask = createRunningTask(1, 'run-1')
|
||||
const pendingTask1 = createPendingTask(2, 'pend-1')
|
||||
const pendingTask2 = createPendingTask(3, 'pend-2')
|
||||
const runningJob = createRunningJob(1, 'run-1')
|
||||
const pendingJob1 = createPendingJob(2, 'pend-1')
|
||||
const pendingJob2 = createPendingJob(3, 'pend-2')
|
||||
|
||||
// API returns pre-sorted data (newest first)
|
||||
mockGetQueue.mockResolvedValue({
|
||||
Running: [runningTask],
|
||||
Pending: [pendingTask1, pendingTask2]
|
||||
Running: [runningJob],
|
||||
Pending: [pendingJob2, pendingJob1] // Pre-sorted by create_time desc
|
||||
})
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -287,13 +290,11 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
|
||||
it('should load history tasks from API', async () => {
|
||||
const historyTask1 = createHistoryTask(5, 'hist-1')
|
||||
const historyTask2 = createHistoryTask(4, 'hist-2')
|
||||
const historyJob1 = createHistoryJob(5, 'hist-1')
|
||||
const historyJob2 = createHistoryJob(4, 'hist-2')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [historyTask1, historyTask2]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([historyJob1, historyJob2])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -304,7 +305,7 @@ describe('useQueueStore', () => {
|
||||
|
||||
it('should set loading state correctly', async () => {
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
expect(store.isLoading).toBe(false)
|
||||
|
||||
@@ -317,7 +318,7 @@ describe('useQueueStore', () => {
|
||||
|
||||
it('should clear loading state even if API fails', async () => {
|
||||
mockGetQueue.mockRejectedValue(new Error('API error'))
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await expect(store.update()).rejects.toThrow('API error')
|
||||
expect(store.isLoading).toBe(false)
|
||||
@@ -326,14 +327,12 @@ describe('useQueueStore', () => {
|
||||
|
||||
describe('update() - sorting', () => {
|
||||
it('should sort tasks by queueIndex descending', async () => {
|
||||
const task1 = createHistoryTask(1, 'hist-1')
|
||||
const task2 = createHistoryTask(5, 'hist-2')
|
||||
const task3 = createHistoryTask(3, 'hist-3')
|
||||
const job1 = createHistoryJob(1, 'hist-1')
|
||||
const job2 = createHistoryJob(5, 'hist-2')
|
||||
const job3 = createHistoryJob(3, 'hist-3')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [task1, task2, task3]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([job1, job2, job3])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -342,16 +341,17 @@ describe('useQueueStore', () => {
|
||||
expect(store.historyTasks[2].queueIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should sort pending tasks by queueIndex descending', async () => {
|
||||
const pend1 = createPendingTask(10, 'pend-1')
|
||||
const pend2 = createPendingTask(15, 'pend-2')
|
||||
const pend3 = createPendingTask(12, 'pend-3')
|
||||
it('should preserve API sort order for pending tasks', async () => {
|
||||
const pend1 = createPendingJob(10, 'pend-1')
|
||||
const pend2 = createPendingJob(15, 'pend-2')
|
||||
const pend3 = createPendingJob(12, 'pend-3')
|
||||
|
||||
// API returns pre-sorted data (newest first)
|
||||
mockGetQueue.mockResolvedValue({
|
||||
Running: [],
|
||||
Pending: [pend1, pend2, pend3]
|
||||
Pending: [pend2, pend3, pend1] // Pre-sorted by create_time desc
|
||||
})
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -363,19 +363,17 @@ describe('useQueueStore', () => {
|
||||
|
||||
describe('update() - queue index collision (THE BUG FIX)', () => {
|
||||
it('should NOT confuse different prompts with same queueIndex', async () => {
|
||||
const hist1 = createHistoryTask(50, 'prompt-uuid-aaa')
|
||||
const hist1 = createHistoryJob(50, 'prompt-uuid-aaa')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [hist1] })
|
||||
mockGetHistory.mockResolvedValue([hist1])
|
||||
|
||||
await store.update()
|
||||
expect(store.historyTasks).toHaveLength(1)
|
||||
expect(store.historyTasks[0].promptId).toBe('prompt-uuid-aaa')
|
||||
|
||||
const hist2 = createHistoryTask(51, 'prompt-uuid-bbb')
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [hist2]
|
||||
})
|
||||
const hist2 = createHistoryJob(51, 'prompt-uuid-bbb')
|
||||
mockGetHistory.mockResolvedValue([hist2])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -385,19 +383,17 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
|
||||
it('should correctly reconcile when queueIndex is reused', async () => {
|
||||
const hist1 = createHistoryTask(100, 'first-prompt-at-100')
|
||||
const hist2 = createHistoryTask(99, 'prompt-at-99')
|
||||
const hist1 = createHistoryJob(100, 'first-prompt-at-100')
|
||||
const hist2 = createHistoryJob(99, 'prompt-at-99')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [hist1, hist2] })
|
||||
mockGetHistory.mockResolvedValue([hist1, hist2])
|
||||
|
||||
await store.update()
|
||||
expect(store.historyTasks).toHaveLength(2)
|
||||
|
||||
const hist3 = createHistoryTask(101, 'second-prompt-at-101')
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [hist3, hist2]
|
||||
})
|
||||
const hist3 = createHistoryJob(101, 'second-prompt-at-101')
|
||||
mockGetHistory.mockResolvedValue([hist3, hist2])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -409,23 +405,19 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
|
||||
it('should handle multiple queueIndex collisions simultaneously', async () => {
|
||||
const hist1 = createHistoryTask(10, 'old-at-10')
|
||||
const hist2 = createHistoryTask(20, 'old-at-20')
|
||||
const hist3 = createHistoryTask(30, 'keep-at-30')
|
||||
const hist1 = createHistoryJob(10, 'old-at-10')
|
||||
const hist2 = createHistoryJob(20, 'old-at-20')
|
||||
const hist3 = createHistoryJob(30, 'keep-at-30')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [hist3, hist2, hist1]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([hist3, hist2, hist1])
|
||||
|
||||
await store.update()
|
||||
expect(store.historyTasks).toHaveLength(3)
|
||||
|
||||
const newHist1 = createHistoryTask(31, 'new-at-31')
|
||||
const newHist2 = createHistoryTask(32, 'new-at-32')
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [newHist2, newHist1, hist3]
|
||||
})
|
||||
const newHist1 = createHistoryJob(31, 'new-at-31')
|
||||
const newHist2 = createHistoryJob(32, 'new-at-32')
|
||||
mockGetHistory.mockResolvedValue([newHist2, newHist1, hist3])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -437,19 +429,17 @@ describe('useQueueStore', () => {
|
||||
|
||||
describe('update() - history reconciliation', () => {
|
||||
it('should keep existing items still on server (by promptId)', async () => {
|
||||
const hist1 = createHistoryTask(10, 'existing-1')
|
||||
const hist2 = createHistoryTask(9, 'existing-2')
|
||||
const hist1 = createHistoryJob(10, 'existing-1')
|
||||
const hist2 = createHistoryJob(9, 'existing-2')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [hist1, hist2] })
|
||||
mockGetHistory.mockResolvedValue([hist1, hist2])
|
||||
|
||||
await store.update()
|
||||
expect(store.historyTasks).toHaveLength(2)
|
||||
|
||||
const hist3 = createHistoryTask(11, 'new-1')
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [hist3, hist1, hist2]
|
||||
})
|
||||
const hist3 = createHistoryJob(11, 'new-1')
|
||||
mockGetHistory.mockResolvedValue([hist3, hist1, hist2])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -460,16 +450,16 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
|
||||
it('should remove items no longer on server', async () => {
|
||||
const hist1 = createHistoryTask(10, 'remove-me')
|
||||
const hist2 = createHistoryTask(9, 'keep-me')
|
||||
const hist1 = createHistoryJob(10, 'remove-me')
|
||||
const hist2 = createHistoryJob(9, 'keep-me')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [hist1, hist2] })
|
||||
mockGetHistory.mockResolvedValue([hist1, hist2])
|
||||
|
||||
await store.update()
|
||||
expect(store.historyTasks).toHaveLength(2)
|
||||
|
||||
mockGetHistory.mockResolvedValue({ History: [hist2] })
|
||||
mockGetHistory.mockResolvedValue([hist2])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -478,18 +468,16 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
|
||||
it('should add new items from server', async () => {
|
||||
const hist1 = createHistoryTask(5, 'old-1')
|
||||
const hist1 = createHistoryJob(5, 'old-1')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [hist1] })
|
||||
mockGetHistory.mockResolvedValue([hist1])
|
||||
|
||||
await store.update()
|
||||
|
||||
const hist2 = createHistoryTask(6, 'new-1')
|
||||
const hist3 = createHistoryTask(7, 'new-2')
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [hist3, hist2, hist1]
|
||||
})
|
||||
const hist2 = createHistoryJob(6, 'new-1')
|
||||
const hist3 = createHistoryJob(7, 'new-2')
|
||||
mockGetHistory.mockResolvedValue([hist3, hist2, hist1])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -497,18 +485,69 @@ describe('useQueueStore', () => {
|
||||
expect(store.historyTasks.map((t) => t.promptId)).toContain('new-1')
|
||||
expect(store.historyTasks.map((t) => t.promptId)).toContain('new-2')
|
||||
})
|
||||
|
||||
it('should recreate TaskItemImpl when outputs_count changes', async () => {
|
||||
// Initial load without outputs_count
|
||||
const jobWithoutOutputsCount = createHistoryJob(10, 'job-1')
|
||||
delete (jobWithoutOutputsCount as any).outputs_count
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue([jobWithoutOutputsCount])
|
||||
|
||||
await store.update()
|
||||
expect(store.historyTasks).toHaveLength(1)
|
||||
const initialTask = store.historyTasks[0]
|
||||
expect(initialTask.outputsCount).toBeUndefined()
|
||||
|
||||
// Second load with outputs_count now populated
|
||||
const jobWithOutputsCount = {
|
||||
...createHistoryJob(10, 'job-1'),
|
||||
outputs_count: 2
|
||||
}
|
||||
mockGetHistory.mockResolvedValue([jobWithOutputsCount])
|
||||
|
||||
await store.update()
|
||||
|
||||
// Should have recreated the TaskItemImpl with new outputs_count
|
||||
expect(store.historyTasks).toHaveLength(1)
|
||||
const updatedTask = store.historyTasks[0]
|
||||
expect(updatedTask.outputsCount).toBe(2)
|
||||
// Should be a different instance
|
||||
expect(updatedTask).not.toBe(initialTask)
|
||||
})
|
||||
|
||||
it('should reuse TaskItemImpl when outputs_count unchanged', async () => {
|
||||
const job = {
|
||||
...createHistoryJob(10, 'job-1'),
|
||||
outputs_count: 2
|
||||
}
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue([job])
|
||||
|
||||
await store.update()
|
||||
const initialTask = store.historyTasks[0]
|
||||
|
||||
// Same job with same outputs_count
|
||||
mockGetHistory.mockResolvedValue([{ ...job }])
|
||||
|
||||
await store.update()
|
||||
|
||||
// Should reuse the same instance
|
||||
expect(store.historyTasks[0]).toBe(initialTask)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update() - maxHistoryItems limit', () => {
|
||||
it('should enforce maxHistoryItems limit', async () => {
|
||||
store.maxHistoryItems = 3
|
||||
|
||||
const tasks = Array.from({ length: 5 }, (_, i) =>
|
||||
createHistoryTask(10 - i, `hist-${i}`)
|
||||
const jobs = Array.from({ length: 5 }, (_, i) =>
|
||||
createHistoryJob(10 - i, `hist-${i}`)
|
||||
)
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: tasks })
|
||||
mockGetHistory.mockResolvedValue(jobs)
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -522,21 +561,19 @@ describe('useQueueStore', () => {
|
||||
store.maxHistoryItems = 5
|
||||
|
||||
const initial = Array.from({ length: 3 }, (_, i) =>
|
||||
createHistoryTask(10 + i, `existing-${i}`)
|
||||
createHistoryJob(10 + i, `existing-${i}`)
|
||||
)
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: initial })
|
||||
mockGetHistory.mockResolvedValue(initial)
|
||||
|
||||
await store.update()
|
||||
expect(store.historyTasks).toHaveLength(3)
|
||||
|
||||
const newTasks = Array.from({ length: 4 }, (_, i) =>
|
||||
createHistoryTask(20 + i, `new-${i}`)
|
||||
const newJobs = Array.from({ length: 4 }, (_, i) =>
|
||||
createHistoryJob(20 + i, `new-${i}`)
|
||||
)
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [...newTasks, ...initial]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([...newJobs, ...initial])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -547,10 +584,10 @@ describe('useQueueStore', () => {
|
||||
it('should handle maxHistoryItems = 0', async () => {
|
||||
store.maxHistoryItems = 0
|
||||
|
||||
const tasks = [createHistoryTask(10, 'hist-1')]
|
||||
const jobs = [createHistoryJob(10, 'hist-1')]
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: tasks })
|
||||
mockGetHistory.mockResolvedValue(jobs)
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -560,13 +597,13 @@ describe('useQueueStore', () => {
|
||||
it('should handle maxHistoryItems = 1', async () => {
|
||||
store.maxHistoryItems = 1
|
||||
|
||||
const tasks = [
|
||||
createHistoryTask(10, 'hist-1'),
|
||||
createHistoryTask(9, 'hist-2')
|
||||
const jobs = [
|
||||
createHistoryJob(10, 'hist-1'),
|
||||
createHistoryJob(9, 'hist-2')
|
||||
]
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: tasks })
|
||||
mockGetHistory.mockResolvedValue(jobs)
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -577,18 +614,18 @@ describe('useQueueStore', () => {
|
||||
it('should dynamically adjust when maxHistoryItems changes', async () => {
|
||||
store.maxHistoryItems = 10
|
||||
|
||||
const tasks = Array.from({ length: 15 }, (_, i) =>
|
||||
createHistoryTask(20 - i, `hist-${i}`)
|
||||
const jobs = Array.from({ length: 15 }, (_, i) =>
|
||||
createHistoryJob(20 - i, `hist-${i}`)
|
||||
)
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: tasks })
|
||||
mockGetHistory.mockResolvedValue(jobs)
|
||||
|
||||
await store.update()
|
||||
expect(store.historyTasks).toHaveLength(10)
|
||||
|
||||
store.maxHistoryItems = 5
|
||||
mockGetHistory.mockResolvedValue({ History: tasks })
|
||||
mockGetHistory.mockResolvedValue(jobs)
|
||||
|
||||
await store.update()
|
||||
expect(store.historyTasks).toHaveLength(5)
|
||||
@@ -597,19 +634,17 @@ describe('useQueueStore', () => {
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('tasks should combine pending, running, and history in correct order', async () => {
|
||||
const running = createRunningTask(5, 'run-1')
|
||||
const pending1 = createPendingTask(6, 'pend-1')
|
||||
const pending2 = createPendingTask(7, 'pend-2')
|
||||
const hist1 = createHistoryTask(3, 'hist-1')
|
||||
const hist2 = createHistoryTask(4, 'hist-2')
|
||||
const running = createRunningJob(5, 'run-1')
|
||||
const pending1 = createPendingJob(6, 'pend-1')
|
||||
const pending2 = createPendingJob(7, 'pend-2')
|
||||
const hist1 = createHistoryJob(3, 'hist-1')
|
||||
const hist2 = createHistoryJob(4, 'hist-2')
|
||||
|
||||
mockGetQueue.mockResolvedValue({
|
||||
Running: [running],
|
||||
Pending: [pending1, pending2]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [hist2, hist1]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([hist2, hist1])
|
||||
|
||||
await store.update()
|
||||
|
||||
@@ -624,9 +659,9 @@ describe('useQueueStore', () => {
|
||||
it('hasPendingTasks should be true when pending tasks exist', async () => {
|
||||
mockGetQueue.mockResolvedValue({
|
||||
Running: [],
|
||||
Pending: [createPendingTask(1, 'pend-1')]
|
||||
Pending: [createPendingJob(1, 'pend-1')]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.update()
|
||||
expect(store.hasPendingTasks).toBe(true)
|
||||
@@ -634,21 +669,19 @@ describe('useQueueStore', () => {
|
||||
|
||||
it('hasPendingTasks should be false when no pending tasks', async () => {
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.update()
|
||||
expect(store.hasPendingTasks).toBe(false)
|
||||
})
|
||||
|
||||
it('lastHistoryQueueIndex should return highest queue index', async () => {
|
||||
const hist1 = createHistoryTask(10, 'hist-1')
|
||||
const hist2 = createHistoryTask(25, 'hist-2')
|
||||
const hist3 = createHistoryTask(15, 'hist-3')
|
||||
const hist1 = createHistoryJob(10, 'hist-1')
|
||||
const hist2 = createHistoryJob(25, 'hist-2')
|
||||
const hist3 = createHistoryJob(15, 'hist-3')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [hist1, hist2, hist3]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([hist1, hist2, hist3])
|
||||
|
||||
await store.update()
|
||||
expect(store.lastHistoryQueueIndex).toBe(25)
|
||||
@@ -656,7 +689,7 @@ describe('useQueueStore', () => {
|
||||
|
||||
it('lastHistoryQueueIndex should be -1 when no history', async () => {
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.update()
|
||||
expect(store.lastHistoryQueueIndex).toBe(-1)
|
||||
@@ -666,19 +699,17 @@ describe('useQueueStore', () => {
|
||||
describe('clear()', () => {
|
||||
beforeEach(async () => {
|
||||
mockGetQueue.mockResolvedValue({
|
||||
Running: [createRunningTask(1, 'run-1')],
|
||||
Pending: [createPendingTask(2, 'pend-1')]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [createHistoryTask(3, 'hist-1')]
|
||||
Running: [createRunningJob(1, 'run-1')],
|
||||
Pending: [createPendingJob(2, 'pend-1')]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([createHistoryJob(3, 'hist-1')])
|
||||
await store.update()
|
||||
})
|
||||
|
||||
it('should clear both queue and history by default', async () => {
|
||||
mockClearItems.mockResolvedValue(undefined)
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.clear()
|
||||
|
||||
@@ -693,9 +724,7 @@ describe('useQueueStore', () => {
|
||||
it('should clear only queue when specified', async () => {
|
||||
mockClearItems.mockResolvedValue(undefined)
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({
|
||||
History: [createHistoryTask(3, 'hist-1')]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue([createHistoryJob(3, 'hist-1')])
|
||||
|
||||
await store.clear(['queue'])
|
||||
|
||||
@@ -707,10 +736,10 @@ describe('useQueueStore', () => {
|
||||
it('should clear only history when specified', async () => {
|
||||
mockClearItems.mockResolvedValue(undefined)
|
||||
mockGetQueue.mockResolvedValue({
|
||||
Running: [createRunningTask(1, 'run-1')],
|
||||
Pending: [createPendingTask(2, 'pend-1')]
|
||||
Running: [createRunningJob(1, 'run-1')],
|
||||
Pending: [createPendingJob(2, 'pend-1')]
|
||||
})
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.clear(['history'])
|
||||
|
||||
@@ -729,11 +758,12 @@ describe('useQueueStore', () => {
|
||||
|
||||
describe('delete()', () => {
|
||||
it('should delete task from queue', async () => {
|
||||
const task = new TaskItemImpl('Pending', createTaskPrompt(1, 'pend-1'))
|
||||
const job = createPendingJob(1, 'pend-1')
|
||||
const task = new TaskItemImpl(job)
|
||||
|
||||
mockDeleteItem.mockResolvedValue(undefined)
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.delete(task)
|
||||
|
||||
@@ -741,16 +771,12 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
|
||||
it('should delete task from history', async () => {
|
||||
const task = new TaskItemImpl(
|
||||
'History',
|
||||
createTaskPrompt(1, 'hist-1'),
|
||||
createTaskStatus(),
|
||||
createTaskOutput()
|
||||
)
|
||||
const job = createHistoryJob(1, 'hist-1')
|
||||
const task = new TaskItemImpl(job, createTaskOutput())
|
||||
|
||||
mockDeleteItem.mockResolvedValue(undefined)
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.delete(task)
|
||||
|
||||
@@ -758,11 +784,12 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
|
||||
it('should refresh store after deletion', async () => {
|
||||
const task = new TaskItemImpl('Pending', createTaskPrompt(1, 'pend-1'))
|
||||
const job = createPendingJob(1, 'pend-1')
|
||||
const task = new TaskItemImpl(job)
|
||||
|
||||
mockDeleteItem.mockResolvedValue(undefined)
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue({ History: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await store.delete(task)
|
||||
|
||||
|
||||
@@ -2,34 +2,27 @@ import _ from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, toRaw, toValue } from 'vue'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { reconcileHistory } from '@/platform/remote/comfyui/history/reconciliation'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { getWorkflowFromHistory } from '@/platform/workflow/cloud'
|
||||
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
APITaskType,
|
||||
JobListItem,
|
||||
TaskType
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
HistoryTaskItem,
|
||||
ResultItem,
|
||||
StatusWsMessageStatus,
|
||||
TaskItem,
|
||||
TaskOutput,
|
||||
TaskPrompt,
|
||||
TaskStatus,
|
||||
TaskType
|
||||
TaskOutput
|
||||
} from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
// Task type used in the API.
|
||||
type APITaskType = 'queue' | 'history'
|
||||
|
||||
enum TaskItemDisplayStatus {
|
||||
Running = 'Running',
|
||||
Pending = 'Pending',
|
||||
@@ -212,32 +205,44 @@ export class ResultItemImpl {
|
||||
get supportsPreview(): boolean {
|
||||
return this.isImage || this.isVideo || this.isAudio || this.is3D
|
||||
}
|
||||
|
||||
static filterPreviewable(
|
||||
outputs: readonly ResultItemImpl[]
|
||||
): ResultItemImpl[] {
|
||||
return outputs.filter((o) => o.supportsPreview)
|
||||
}
|
||||
|
||||
static findByUrl(items: readonly ResultItemImpl[], url?: string): number {
|
||||
if (!url) return 0
|
||||
const idx = items.findIndex((o) => o.url === url)
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskItemImpl {
|
||||
readonly taskType: TaskType
|
||||
readonly prompt: TaskPrompt
|
||||
readonly status?: TaskStatus
|
||||
readonly job: JobListItem
|
||||
readonly outputs: TaskOutput
|
||||
readonly flatOutputs: ReadonlyArray<ResultItemImpl>
|
||||
|
||||
constructor(
|
||||
taskType: TaskType,
|
||||
prompt: TaskPrompt,
|
||||
status?: TaskStatus,
|
||||
job: JobListItem,
|
||||
outputs?: TaskOutput,
|
||||
flatOutputs?: ReadonlyArray<ResultItemImpl>
|
||||
) {
|
||||
this.taskType = taskType
|
||||
this.prompt = prompt
|
||||
this.status = status
|
||||
this.job = job
|
||||
// If no outputs provided but job has preview_output, create synthetic outputs
|
||||
// using the real nodeId and mediaType from the backend response
|
||||
const effectiveOutputs =
|
||||
outputs ??
|
||||
(job.preview_output
|
||||
? {
|
||||
[job.preview_output.nodeId]: {
|
||||
[job.preview_output.mediaType]: [job.preview_output]
|
||||
}
|
||||
}
|
||||
: {})
|
||||
// Remove animated outputs from the outputs object
|
||||
// outputs.animated is an array of boolean values that indicates if the images
|
||||
// array in the result are animated or not.
|
||||
// The queueStore does not use this information.
|
||||
// It is part of the legacy API response. We should redesign the backend API.
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/2739
|
||||
this.outputs = _.mapValues(outputs ?? {}, (nodeOutputs) =>
|
||||
this.outputs = _.mapValues(effectiveOutputs, (nodeOutputs) =>
|
||||
_.omit(nodeOutputs, 'animated')
|
||||
)
|
||||
this.flatOutputs = flatOutputs ?? this.calculateFlatOutputs()
|
||||
@@ -261,15 +266,31 @@ export class TaskItemImpl {
|
||||
)
|
||||
}
|
||||
|
||||
/** All outputs that support preview (images, videos, audio, 3D) */
|
||||
get previewableOutputs(): readonly ResultItemImpl[] {
|
||||
return ResultItemImpl.filterPreviewable(this.flatOutputs)
|
||||
}
|
||||
|
||||
get previewOutput(): ResultItemImpl | undefined {
|
||||
const previewable = this.previewableOutputs
|
||||
// Prefer saved media files over the temp previews
|
||||
return (
|
||||
this.flatOutputs.find(
|
||||
// Prefer saved media files over the temp previews
|
||||
(output) => output.type === 'output' && output.supportsPreview
|
||||
) ?? this.flatOutputs.find((output) => output.supportsPreview)
|
||||
previewable.find((output) => output.type === 'output') ?? previewable[0]
|
||||
)
|
||||
}
|
||||
|
||||
// Derive taskType from job status
|
||||
get taskType(): TaskType {
|
||||
switch (this.job.status) {
|
||||
case 'in_progress':
|
||||
return 'Running'
|
||||
case 'pending':
|
||||
return 'Pending'
|
||||
default:
|
||||
return 'History'
|
||||
}
|
||||
}
|
||||
|
||||
get apiTaskType(): APITaskType {
|
||||
switch (this.taskType) {
|
||||
case 'Running':
|
||||
@@ -285,61 +306,42 @@ export class TaskItemImpl {
|
||||
}
|
||||
|
||||
get queueIndex() {
|
||||
return this.prompt[0]
|
||||
return this.job.priority
|
||||
}
|
||||
|
||||
get promptId() {
|
||||
return this.prompt[1]
|
||||
return this.job.id
|
||||
}
|
||||
|
||||
get promptInputs() {
|
||||
return this.prompt[2]
|
||||
get outputsCount(): number | undefined {
|
||||
return this.job.outputs_count ?? undefined
|
||||
}
|
||||
|
||||
get extraData() {
|
||||
return this.prompt[3]
|
||||
get status() {
|
||||
return this.job.status
|
||||
}
|
||||
|
||||
get outputsToExecute() {
|
||||
return this.prompt[4]
|
||||
get errorMessage(): string | undefined {
|
||||
return this.job.execution_error?.exception_message ?? undefined
|
||||
}
|
||||
|
||||
get extraPngInfo() {
|
||||
return this.extraData.extra_pnginfo
|
||||
get executionError() {
|
||||
return this.job.execution_error ?? undefined
|
||||
}
|
||||
|
||||
get clientId() {
|
||||
return this.extraData.client_id
|
||||
get workflowId(): string | undefined {
|
||||
return this.job.workflow_id ?? undefined
|
||||
}
|
||||
|
||||
get workflow(): ComfyWorkflowJSON | undefined {
|
||||
return this.extraPngInfo?.workflow
|
||||
get createTime(): number {
|
||||
return this.job.create_time
|
||||
}
|
||||
|
||||
get messages() {
|
||||
return this.status?.messages || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-provided creation time in milliseconds, when available.
|
||||
*
|
||||
* Sources:
|
||||
* - Queue: 5th tuple element may be a metadata object with { create_time }.
|
||||
* - History (Cloud V2): Adapter injects create_time into prompt[3].extra_data.
|
||||
*/
|
||||
get createTime(): number | undefined {
|
||||
const extra = (this.extraData as any) || {}
|
||||
const fromExtra =
|
||||
typeof extra.create_time === 'number' ? extra.create_time : undefined
|
||||
if (typeof fromExtra === 'number') return fromExtra
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
get interrupted() {
|
||||
return _.some(
|
||||
this.messages,
|
||||
(message) => message[0] === 'execution_interrupted'
|
||||
get interrupted(): boolean {
|
||||
return (
|
||||
this.job.status === 'failed' &&
|
||||
this.job.execution_error?.exception_type ===
|
||||
'InterruptProcessingException'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -352,42 +354,26 @@ export class TaskItemImpl {
|
||||
}
|
||||
|
||||
get displayStatus(): TaskItemDisplayStatus {
|
||||
switch (this.taskType) {
|
||||
case 'Running':
|
||||
switch (this.job.status) {
|
||||
case 'in_progress':
|
||||
return TaskItemDisplayStatus.Running
|
||||
case 'Pending':
|
||||
case 'pending':
|
||||
return TaskItemDisplayStatus.Pending
|
||||
case 'History':
|
||||
if (this.interrupted) return TaskItemDisplayStatus.Cancelled
|
||||
|
||||
switch (this.status!.status_str) {
|
||||
case 'success':
|
||||
return TaskItemDisplayStatus.Completed
|
||||
case 'error':
|
||||
return TaskItemDisplayStatus.Failed
|
||||
}
|
||||
case 'completed':
|
||||
return TaskItemDisplayStatus.Completed
|
||||
case 'failed':
|
||||
return TaskItemDisplayStatus.Failed
|
||||
case 'cancelled':
|
||||
return TaskItemDisplayStatus.Cancelled
|
||||
}
|
||||
}
|
||||
|
||||
get executionStartTimestamp() {
|
||||
const message = this.messages.find(
|
||||
(message) => message[0] === 'execution_start'
|
||||
)
|
||||
return message ? message[1].timestamp : undefined
|
||||
return this.job.execution_start_time ?? undefined
|
||||
}
|
||||
|
||||
get executionEndTimestamp() {
|
||||
const messages = this.messages.filter((message) =>
|
||||
[
|
||||
'execution_success',
|
||||
'execution_interrupted',
|
||||
'execution_error'
|
||||
].includes(message[0])
|
||||
)
|
||||
if (!messages.length) {
|
||||
return undefined
|
||||
}
|
||||
return _.max(messages.map((message) => message[1].timestamp))
|
||||
return this.job.execution_end_time ?? undefined
|
||||
}
|
||||
|
||||
get executionTime() {
|
||||
@@ -403,28 +389,48 @@ export class TaskItemImpl {
|
||||
: undefined
|
||||
}
|
||||
|
||||
public async loadWorkflow(app: ComfyApp) {
|
||||
let workflowData = this.workflow
|
||||
/**
|
||||
* Loads full outputs for tasks that only have preview data
|
||||
* Returns a new TaskItemImpl with full outputs and execution status
|
||||
*/
|
||||
public async loadFullOutputs(): Promise<TaskItemImpl> {
|
||||
// Only load for history tasks (caller checks outputsCount > 1)
|
||||
if (!this.isHistory) {
|
||||
return this
|
||||
}
|
||||
const jobDetail = await getJobDetail(this.promptId)
|
||||
|
||||
if (isCloud && !workflowData && this.isHistory) {
|
||||
workflowData = await getWorkflowFromHistory(
|
||||
(url) => app.api.fetchApi(url),
|
||||
this.promptId
|
||||
)
|
||||
if (!jobDetail?.outputs) {
|
||||
return this
|
||||
}
|
||||
|
||||
// Create new TaskItemImpl with full outputs
|
||||
return new TaskItemImpl(this.job, jobDetail.outputs)
|
||||
}
|
||||
|
||||
public async loadWorkflow(app: ComfyApp) {
|
||||
if (!this.isHistory) {
|
||||
return
|
||||
}
|
||||
|
||||
// Single fetch for both workflow and outputs (with caching)
|
||||
const jobDetail = await getJobDetail(this.promptId)
|
||||
|
||||
const workflowData = await extractWorkflow(jobDetail)
|
||||
if (!workflowData) {
|
||||
return
|
||||
}
|
||||
|
||||
await app.loadGraphData(toRaw(workflowData))
|
||||
|
||||
if (!this.outputs) {
|
||||
// Use full outputs from job detail, or fall back to existing outputs
|
||||
const outputsToLoad = jobDetail?.outputs ?? this.outputs
|
||||
if (!outputsToLoad) {
|
||||
return
|
||||
}
|
||||
|
||||
const nodeOutputsStore = useNodeOutputStore()
|
||||
const rawOutputs = toRaw(this.outputs)
|
||||
const rawOutputs = toRaw(outputsToLoad)
|
||||
for (const nodeExecutionId in rawOutputs) {
|
||||
nodeOutputsStore.setNodeOutputsByExecutionId(
|
||||
nodeExecutionId,
|
||||
@@ -445,15 +451,10 @@ export class TaskItemImpl {
|
||||
return this.flatOutputs.map(
|
||||
(output: ResultItemImpl, i: number) =>
|
||||
new TaskItemImpl(
|
||||
this.taskType,
|
||||
[
|
||||
this.queueIndex,
|
||||
`${this.promptId}-${i}`,
|
||||
this.promptInputs,
|
||||
this.extraData,
|
||||
this.outputsToExecute
|
||||
],
|
||||
this.status,
|
||||
{
|
||||
...this.job,
|
||||
id: `${this.promptId}-${i}`
|
||||
},
|
||||
{
|
||||
[output.nodeId]: {
|
||||
[output.mediaType]: [output]
|
||||
@@ -463,32 +464,8 @@ export class TaskItemImpl {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public toTaskItem(): TaskItem {
|
||||
const item: HistoryTaskItem = {
|
||||
taskType: 'History',
|
||||
prompt: this.prompt,
|
||||
status: this.status!,
|
||||
outputs: this.outputs
|
||||
}
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
const sortNewestFirst = (a: TaskItemImpl, b: TaskItemImpl) =>
|
||||
b.queueIndex - a.queueIndex
|
||||
|
||||
const toTaskItemImpls = (tasks: TaskItem[]): TaskItemImpl[] =>
|
||||
tasks.map(
|
||||
(task) =>
|
||||
new TaskItemImpl(
|
||||
task.taskType,
|
||||
task.prompt,
|
||||
'status' in task ? task.status : undefined,
|
||||
'outputs' in task ? task.outputs : undefined
|
||||
)
|
||||
)
|
||||
|
||||
export const useQueueStore = defineStore('queue', () => {
|
||||
// Use shallowRef because TaskItemImpl instances are immutable and arrays are
|
||||
// replaced entirely (not mutated), so deep reactivity would waste performance
|
||||
@@ -525,8 +502,9 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
api.getHistory(maxHistoryItems.value)
|
||||
])
|
||||
|
||||
runningTasks.value = toTaskItemImpls(queue.Running).sort(sortNewestFirst)
|
||||
pendingTasks.value = toTaskItemImpls(queue.Pending).sort(sortNewestFirst)
|
||||
// API returns pre-sorted data (sort_by=create_time&order=desc)
|
||||
runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
|
||||
pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
|
||||
|
||||
const currentHistory = toValue(historyTasks)
|
||||
|
||||
@@ -534,7 +512,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
const executionStore = useExecutionStore()
|
||||
appearedTasks.forEach((task) => {
|
||||
const promptIdString = String(task.promptId)
|
||||
const workflowId = task.workflow?.id
|
||||
const workflowId = task.workflowId
|
||||
if (workflowId && promptIdString) {
|
||||
executionStore.registerPromptWorkflowIdMapping(
|
||||
promptIdString,
|
||||
@@ -543,22 +521,26 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const items = reconcileHistory(
|
||||
history.History,
|
||||
currentHistory.map((impl) => impl.toTaskItem()),
|
||||
toValue(maxHistoryItems),
|
||||
toValue(lastHistoryQueueIndex)
|
||||
)
|
||||
// Sort by create_time descending and limit to maxItems
|
||||
const sortedHistory = [...history]
|
||||
.sort((a, b) => b.create_time - a.create_time)
|
||||
.slice(0, toValue(maxHistoryItems))
|
||||
|
||||
// Reuse existing TaskItemImpl instances or create new
|
||||
// Must recreate if outputs_count changed (e.g., API started returning it)
|
||||
const existingByPromptId = new Map(
|
||||
currentHistory.map((impl) => [impl.promptId, impl])
|
||||
)
|
||||
|
||||
historyTasks.value = items.map(
|
||||
(item) =>
|
||||
existingByPromptId.get(item.prompt[1]) ?? toTaskItemImpls([item])[0]
|
||||
)
|
||||
historyTasks.value = sortedHistory.map((job) => {
|
||||
const existing = existingByPromptId.get(job.id)
|
||||
if (!existing) return new TaskItemImpl(job)
|
||||
// Recreate if outputs_count changed to ensure lazy loading works
|
||||
if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
|
||||
return new TaskItemImpl(job)
|
||||
}
|
||||
return existing
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user