mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
## Summary Add a frontend preview extension for the SaveText node that displays saved text content after execution. ## Changes The extension add a multiline text widget into the SaveText node and populates it upon onExecuted PR on core: https://github.com/Comfy-Org/ComfyUI/pull/14102 ## Review Focus <!-- Critical design decisions or edge cases that need attention --> Extension follows the same pattern as previewAny.ts <!-- If this PR fixes an issue, uncomment and update the line below --> <!-- Fixes #ISSUE_NUMBER --> ## Screenshots <img width="1127" height="421" alt="Screenshot 2026-05-29 194925" src="https://github.com/user-attachments/assets/e7a72807-858b-47c7-be07-595f9e539a49" /> <!-- Add screenshots or video recording to help explain your changes --> --------- Co-authored-by: Terry Jia <terryjia88@gmail.com>
1141 lines
36 KiB
TypeScript
1141 lines
36 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
|
import type { TaskOutput } from '@/schemas/apiSchema'
|
|
import { api } from '@/scripts/api'
|
|
import { useExecutionStore } from '@/stores/executionStore'
|
|
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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',
|
|
images: {
|
|
type?: 'output' | 'input' | 'temp'
|
|
filename?: string
|
|
subfolder?: string
|
|
}[] = []
|
|
): TaskOutput => ({
|
|
[nodeId]: {
|
|
images
|
|
}
|
|
})
|
|
|
|
type QueueResponse = { Running: JobListItem[]; Pending: JobListItem[] }
|
|
type QueueResolver = (value: QueueResponse) => void
|
|
|
|
// Mock API
|
|
vi.mock('@/scripts/api', () => ({
|
|
api: {
|
|
getQueue: vi.fn(),
|
|
getHistory: vi.fn(),
|
|
clearItems: vi.fn(),
|
|
deleteItem: vi.fn(),
|
|
apiURL: vi.fn((path) => `/api${path}`),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn()
|
|
}
|
|
}))
|
|
|
|
describe('TaskItemImpl', () => {
|
|
it('should exclude animated from flatOutputs', () => {
|
|
const job = createHistoryJob(0, 'job-id')
|
|
const taskItem = new TaskItemImpl(job, {
|
|
'node-1': {
|
|
images: [{ filename: 'test.png', type: 'output', subfolder: '' }],
|
|
animated: [false]
|
|
}
|
|
})
|
|
|
|
expect(taskItem.flatOutputs).toHaveLength(1)
|
|
expect(taskItem.flatOutputs[0].filename).toBe('test.png')
|
|
expect(taskItem.flatOutputs[0].mediaType).toBe('images')
|
|
})
|
|
|
|
it('should handle outputs without animated property', () => {
|
|
const job = createHistoryJob(0, 'job-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 job = createHistoryJob(0, 'job-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.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 job = createHistoryJob(0, 'job-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.isVhsFormat).toBe(true)
|
|
expect(output.isImage).toBe(false)
|
|
})
|
|
|
|
it('should recognize mp4 video from core', () => {
|
|
const job = createHistoryJob(0, 'job-id')
|
|
const taskItem = new TaskItemImpl(job, {
|
|
'node-1': {
|
|
images: [
|
|
{
|
|
filename: 'test.mp4',
|
|
type: 'output',
|
|
subfolder: ''
|
|
}
|
|
],
|
|
animated: [true]
|
|
}
|
|
})
|
|
|
|
const output = taskItem.flatOutputs[0]
|
|
|
|
expect(output.htmlVideoType).toBe('video/mp4')
|
|
expect(output.isVideo).toBe(true)
|
|
expect(output.isImage).toBe(false)
|
|
})
|
|
|
|
describe('audio format detection', () => {
|
|
const audioFormats = [
|
|
{ extension: 'mp3', mimeType: 'audio/mpeg' },
|
|
{ extension: 'wav', mimeType: 'audio/wav' },
|
|
{ extension: 'ogg', mimeType: 'audio/ogg' },
|
|
{ extension: 'flac', mimeType: 'audio/flac' }
|
|
]
|
|
|
|
audioFormats.forEach(({ extension, mimeType }) => {
|
|
it(`should recognize ${extension} audio`, () => {
|
|
const job = createHistoryJob(0, 'job-id')
|
|
const taskItem = new TaskItemImpl(job, {
|
|
'node-1': {
|
|
audio: [
|
|
{
|
|
filename: `test.${extension}`,
|
|
type: 'output',
|
|
subfolder: ''
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
const output = taskItem.flatOutputs[0]
|
|
|
|
expect(output.htmlAudioType).toBe(mimeType)
|
|
expect(output.isAudio).toBe(true)
|
|
expect(output.isVideo).toBe(false)
|
|
expect(output.isImage).toBe(false)
|
|
expect(output.supportsPreview).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should recognize text files saved under the files output key', () => {
|
|
const job = createHistoryJob(0, 'job-id')
|
|
const taskItem = new TaskItemImpl(job, {
|
|
'node-1': {
|
|
files: [
|
|
{
|
|
filename: 'result.txt',
|
|
type: 'output',
|
|
subfolder: ''
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
const output = taskItem.flatOutputs[0]
|
|
|
|
expect(output.isText).toBe(true)
|
|
expect(output.isImage).toBe(false)
|
|
expect(output.isVideo).toBe(false)
|
|
expect(output.isAudio).toBe(false)
|
|
expect(output.supportsPreview).toBe(true)
|
|
})
|
|
|
|
it.skip('should parse text outputs', () => {
|
|
const job: JobListItem = {
|
|
...createHistoryJob(0, 'text-job'),
|
|
preview_output: {
|
|
nodeId: '5',
|
|
mediaType: 'text',
|
|
content: 'test'
|
|
} satisfies JobListItem['preview_output']
|
|
}
|
|
|
|
const task = new TaskItemImpl(job)
|
|
|
|
expect(task.flatOutputs).toHaveLength(1)
|
|
expect(task.flatOutputs[0].filename).toBe('')
|
|
expect(task.previewableOutputs).toHaveLength(1)
|
|
expect(task.previewOutput?.content).toBe('test')
|
|
})
|
|
|
|
describe('error extraction getters', () => {
|
|
it('errorMessage returns undefined when no execution_error', () => {
|
|
const job = createHistoryJob(0, 'job-id')
|
|
const taskItem = new TaskItemImpl(job)
|
|
expect(taskItem.errorMessage).toBeUndefined()
|
|
})
|
|
|
|
it('errorMessage returns the exception_message from execution_error', () => {
|
|
const job: JobListItem = {
|
|
...createHistoryJob(0, 'job-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, 'job-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, 'job-id'),
|
|
status: 'failed',
|
|
execution_error: errorDetail
|
|
}
|
|
const taskItem = new TaskItemImpl(job)
|
|
expect(taskItem.executionError).toEqual(errorDetail)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('useQueueStore', () => {
|
|
let store: ReturnType<typeof useQueueStore>
|
|
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
store = useQueueStore()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
const mockGetQueue = vi.mocked(api.getQueue)
|
|
const mockGetHistory = vi.mocked(api.getHistory)
|
|
const mockClearItems = vi.mocked(api.clearItems)
|
|
const mockDeleteItem = vi.mocked(api.deleteItem)
|
|
|
|
describe('initial state', () => {
|
|
it('should have empty state on initialization', () => {
|
|
expect(store.runningTasks).toEqual([])
|
|
expect(store.pendingTasks).toEqual([])
|
|
expect(store.historyTasks).toEqual([])
|
|
expect(store.isLoading).toBe(false)
|
|
expect(store.maxHistoryItems).toBe(64)
|
|
})
|
|
|
|
it('should have empty computed tasks', () => {
|
|
expect(store.tasks).toEqual([])
|
|
expect(store.flatTasks).toEqual([])
|
|
expect(store.hasPendingTasks).toBe(false)
|
|
expect(store.lastJobHistoryPriority).toBe(-1)
|
|
})
|
|
})
|
|
|
|
describe('update() - basic functionality', () => {
|
|
it('should load running and pending tasks from API', async () => {
|
|
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: [runningJob],
|
|
Pending: [pendingJob2, pendingJob1] // Pre-sorted by create_time desc
|
|
})
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.update()
|
|
|
|
expect(store.runningTasks).toHaveLength(1)
|
|
expect(store.pendingTasks).toHaveLength(2)
|
|
expect(store.runningTasks[0].jobId).toBe('run-1')
|
|
expect(store.pendingTasks[0].jobId).toBe('pend-2')
|
|
expect(store.pendingTasks[1].jobId).toBe('pend-1')
|
|
})
|
|
|
|
it('should load history tasks from API', async () => {
|
|
const historyJob1 = createHistoryJob(5, 'hist-1')
|
|
const historyJob2 = createHistoryJob(4, 'hist-2')
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([historyJob1, historyJob2])
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(2)
|
|
expect(store.historyTasks[0].jobId).toBe('hist-1')
|
|
expect(store.historyTasks[1].jobId).toBe('hist-2')
|
|
})
|
|
|
|
it('should set loading state correctly', async () => {
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
expect(store.isLoading).toBe(false)
|
|
|
|
const updatePromise = store.update()
|
|
expect(store.isLoading).toBe(true)
|
|
|
|
await updatePromise
|
|
expect(store.isLoading).toBe(false)
|
|
})
|
|
|
|
it('should clear loading state even if the queue fetch fails', async () => {
|
|
mockGetQueue.mockRejectedValue(new Error('API error'))
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.update()
|
|
expect(store.isLoading).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('update() - single-flight coalescing', () => {
|
|
it('should coalesce concurrent calls into one re-fetch', async () => {
|
|
let resolveQueue!: QueueResolver
|
|
mockGetQueue.mockImplementation(
|
|
() =>
|
|
new Promise<QueueResponse>((resolve) => {
|
|
resolveQueue = resolve
|
|
})
|
|
)
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
// First call starts the in-flight request
|
|
const first = store.update()
|
|
expect(mockGetQueue).toHaveBeenCalledTimes(1)
|
|
|
|
// These calls arrive while the first is in flight — they should coalesce
|
|
void store.update()
|
|
void store.update()
|
|
void store.update()
|
|
|
|
// No additional HTTP requests fired
|
|
expect(mockGetQueue).toHaveBeenCalledTimes(1)
|
|
|
|
// Resolve the in-flight request
|
|
resolveQueue({ Running: [], Pending: [] })
|
|
await first
|
|
|
|
// A single re-fetch should fire because dirty was set
|
|
expect(mockGetQueue).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('should apply every response (no starvation)', async () => {
|
|
const firstRunning = createRunningJob(1, 'run-1')
|
|
const secondRunning = createRunningJob(2, 'run-2')
|
|
|
|
let resolveQueue!: QueueResolver
|
|
mockGetQueue.mockImplementation(
|
|
() =>
|
|
new Promise<QueueResponse>((resolve) => {
|
|
resolveQueue = resolve
|
|
})
|
|
)
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
// First call
|
|
const first = store.update()
|
|
|
|
// Second call coalesces
|
|
void store.update()
|
|
|
|
// Resolve first — data should be applied (not discarded)
|
|
resolveQueue({ Running: [firstRunning], Pending: [] })
|
|
await first
|
|
|
|
expect(store.runningTasks).toHaveLength(1)
|
|
expect(store.runningTasks[0].jobId).toBe('run-1')
|
|
|
|
// The coalesced re-fetch fires; resolve it with new data
|
|
resolveQueue({ Running: [secondRunning], Pending: [] })
|
|
// Wait for the re-fetch to complete
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(store.runningTasks).toHaveLength(1)
|
|
expect(store.runningTasks[0].jobId).toBe('run-2')
|
|
})
|
|
|
|
it('should not fire duplicate requests when no calls arrive during flight', async () => {
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.update()
|
|
|
|
expect(mockGetQueue).toHaveBeenCalledTimes(1)
|
|
expect(mockGetHistory).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should clear loading state after coalesced re-fetch completes', async () => {
|
|
let resolveQueue!: QueueResolver
|
|
mockGetQueue.mockImplementation(
|
|
() =>
|
|
new Promise<QueueResponse>((resolve) => {
|
|
resolveQueue = resolve
|
|
})
|
|
)
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
const first = store.update()
|
|
void store.update() // coalesce
|
|
|
|
resolveQueue({ Running: [], Pending: [] })
|
|
await first
|
|
|
|
// isLoading should be true again for the re-fetch
|
|
expect(store.isLoading).toBe(true)
|
|
|
|
resolveQueue({ Running: [], Pending: [] })
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(store.isLoading).toBe(false)
|
|
})
|
|
|
|
it('should allow new requests after coalesced re-fetch completes', async () => {
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.update()
|
|
expect(mockGetQueue).toHaveBeenCalledTimes(1)
|
|
|
|
await store.update()
|
|
expect(mockGetQueue).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
describe('update() - sorting', () => {
|
|
it('should sort tasks by job.priority descending', async () => {
|
|
const job1 = createHistoryJob(1, 'hist-1')
|
|
const job2 = createHistoryJob(5, 'hist-2')
|
|
const job3 = createHistoryJob(3, 'hist-3')
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([job1, job2, job3])
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks[0].job.priority).toBe(5)
|
|
expect(store.historyTasks[1].job.priority).toBe(3)
|
|
expect(store.historyTasks[2].job.priority).toBe(1)
|
|
})
|
|
|
|
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: [pend2, pend3, pend1] // Pre-sorted by create_time desc
|
|
})
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.update()
|
|
|
|
expect(store.pendingTasks[0].job.priority).toBe(15)
|
|
expect(store.pendingTasks[1].job.priority).toBe(12)
|
|
expect(store.pendingTasks[2].job.priority).toBe(10)
|
|
})
|
|
})
|
|
|
|
describe('update() - queue index collision (THE BUG FIX)', () => {
|
|
it('should NOT confuse different prompts with same job.priority', async () => {
|
|
const hist1 = createHistoryJob(50, 'prompt-uuid-aaa')
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([hist1])
|
|
|
|
await store.update()
|
|
expect(store.historyTasks).toHaveLength(1)
|
|
expect(store.historyTasks[0].jobId).toBe('prompt-uuid-aaa')
|
|
|
|
const hist2 = createHistoryJob(51, 'prompt-uuid-bbb')
|
|
mockGetHistory.mockResolvedValue([hist2])
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(1)
|
|
expect(store.historyTasks[0].jobId).toBe('prompt-uuid-bbb')
|
|
expect(store.historyTasks[0].job.priority).toBe(51)
|
|
})
|
|
|
|
it('should correctly reconcile when job.priority is reused', async () => {
|
|
const hist1 = createHistoryJob(100, 'first-prompt-at-100')
|
|
const hist2 = createHistoryJob(99, 'prompt-at-99')
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([hist1, hist2])
|
|
|
|
await store.update()
|
|
expect(store.historyTasks).toHaveLength(2)
|
|
|
|
const hist3 = createHistoryJob(101, 'second-prompt-at-101')
|
|
mockGetHistory.mockResolvedValue([hist3, hist2])
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(2)
|
|
const jobIds = store.historyTasks.map((t) => t.jobId)
|
|
expect(jobIds).toContain('second-prompt-at-101')
|
|
expect(jobIds).toContain('prompt-at-99')
|
|
expect(jobIds).not.toContain('first-prompt-at-100')
|
|
})
|
|
|
|
it('should handle multiple job.priority collisions simultaneously', async () => {
|
|
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([hist3, hist2, hist1])
|
|
|
|
await store.update()
|
|
expect(store.historyTasks).toHaveLength(3)
|
|
|
|
const newHist1 = createHistoryJob(31, 'new-at-31')
|
|
const newHist2 = createHistoryJob(32, 'new-at-32')
|
|
mockGetHistory.mockResolvedValue([newHist2, newHist1, hist3])
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(3)
|
|
const jobIds = store.historyTasks.map((t) => t.jobId)
|
|
expect(jobIds).toEqual(['new-at-32', 'new-at-31', 'keep-at-30'])
|
|
})
|
|
})
|
|
|
|
describe('update() - history reconciliation', () => {
|
|
it('should keep existing items still on server (by jobId)', async () => {
|
|
const hist1 = createHistoryJob(10, 'existing-1')
|
|
const hist2 = createHistoryJob(9, 'existing-2')
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([hist1, hist2])
|
|
|
|
await store.update()
|
|
expect(store.historyTasks).toHaveLength(2)
|
|
|
|
const hist3 = createHistoryJob(11, 'new-1')
|
|
mockGetHistory.mockResolvedValue([hist3, hist1, hist2])
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(3)
|
|
expect(store.historyTasks.map((t) => t.jobId)).toContain('existing-1')
|
|
expect(store.historyTasks.map((t) => t.jobId)).toContain('existing-2')
|
|
expect(store.historyTasks.map((t) => t.jobId)).toContain('new-1')
|
|
})
|
|
|
|
it('should remove items no longer on server', async () => {
|
|
const hist1 = createHistoryJob(10, 'remove-me')
|
|
const hist2 = createHistoryJob(9, 'keep-me')
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([hist1, hist2])
|
|
|
|
await store.update()
|
|
expect(store.historyTasks).toHaveLength(2)
|
|
|
|
mockGetHistory.mockResolvedValue([hist2])
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(1)
|
|
expect(store.historyTasks[0].jobId).toBe('keep-me')
|
|
})
|
|
|
|
it('should add new items from server', async () => {
|
|
const hist1 = createHistoryJob(5, 'old-1')
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([hist1])
|
|
|
|
await store.update()
|
|
|
|
const hist2 = createHistoryJob(6, 'new-1')
|
|
const hist3 = createHistoryJob(7, 'new-2')
|
|
mockGetHistory.mockResolvedValue([hist3, hist2, hist1])
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(3)
|
|
expect(store.historyTasks.map((t) => t.jobId)).toContain('new-1')
|
|
expect(store.historyTasks.map((t) => t.jobId)).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.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]
|
|
const initialHistoryTasks = store.historyTasks
|
|
|
|
// Same job with same outputs_count
|
|
mockGetHistory.mockResolvedValue([{ ...job }])
|
|
|
|
await store.update()
|
|
|
|
// Should reuse the same instance
|
|
expect(store.historyTasks[0]).toBe(initialTask)
|
|
// Should preserve array identity when history is unchanged
|
|
expect(store.historyTasks).toBe(initialHistoryTasks)
|
|
})
|
|
})
|
|
|
|
describe('update() - maxHistoryItems limit', () => {
|
|
it('should enforce maxHistoryItems limit', async () => {
|
|
store.maxHistoryItems = 3
|
|
|
|
const jobs = Array.from({ length: 5 }, (_, i) =>
|
|
createHistoryJob(10 - i, `hist-${i}`)
|
|
)
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue(jobs)
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(3)
|
|
expect(store.historyTasks[0].job.priority).toBe(10)
|
|
expect(store.historyTasks[1].job.priority).toBe(9)
|
|
expect(store.historyTasks[2].job.priority).toBe(8)
|
|
})
|
|
|
|
it('should respect maxHistoryItems when combining new and existing', async () => {
|
|
store.maxHistoryItems = 5
|
|
|
|
const initial = Array.from({ length: 3 }, (_, i) =>
|
|
createHistoryJob(10 + i, `existing-${i}`)
|
|
)
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue(initial)
|
|
|
|
await store.update()
|
|
expect(store.historyTasks).toHaveLength(3)
|
|
|
|
const newJobs = Array.from({ length: 4 }, (_, i) =>
|
|
createHistoryJob(20 + i, `new-${i}`)
|
|
)
|
|
mockGetHistory.mockResolvedValue([...newJobs, ...initial])
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(5)
|
|
expect(store.historyTasks[0].job.priority).toBe(23)
|
|
})
|
|
|
|
it('should handle maxHistoryItems = 0', async () => {
|
|
store.maxHistoryItems = 0
|
|
|
|
const jobs = [createHistoryJob(10, 'hist-1')]
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue(jobs)
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(0)
|
|
})
|
|
|
|
it('should handle maxHistoryItems = 1', async () => {
|
|
store.maxHistoryItems = 1
|
|
|
|
const jobs = [
|
|
createHistoryJob(10, 'hist-1'),
|
|
createHistoryJob(9, 'hist-2')
|
|
]
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue(jobs)
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(1)
|
|
expect(store.historyTasks[0].job.priority).toBe(10)
|
|
})
|
|
|
|
it('should dynamically adjust when maxHistoryItems changes', async () => {
|
|
store.maxHistoryItems = 10
|
|
|
|
const jobs = Array.from({ length: 15 }, (_, i) =>
|
|
createHistoryJob(20 - i, `hist-${i}`)
|
|
)
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue(jobs)
|
|
|
|
await store.update()
|
|
expect(store.historyTasks).toHaveLength(10)
|
|
|
|
store.maxHistoryItems = 5
|
|
mockGetHistory.mockResolvedValue(jobs)
|
|
|
|
await store.update()
|
|
expect(store.historyTasks).toHaveLength(5)
|
|
})
|
|
})
|
|
|
|
describe('computed properties', () => {
|
|
it('tasks should combine pending, running, and history in correct order', async () => {
|
|
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([hist2, hist1])
|
|
|
|
await store.update()
|
|
|
|
expect(store.tasks).toHaveLength(5)
|
|
expect(store.tasks[0].taskType).toBe('Pending')
|
|
expect(store.tasks[1].taskType).toBe('Pending')
|
|
expect(store.tasks[2].taskType).toBe('Running')
|
|
expect(store.tasks[3].taskType).toBe('History')
|
|
expect(store.tasks[4].taskType).toBe('History')
|
|
})
|
|
|
|
it('hasPendingTasks should be true when pending tasks exist', async () => {
|
|
mockGetQueue.mockResolvedValue({
|
|
Running: [],
|
|
Pending: [createPendingJob(1, 'pend-1')]
|
|
})
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.update()
|
|
expect(store.hasPendingTasks).toBe(true)
|
|
})
|
|
|
|
it('hasPendingTasks should be false when no pending tasks', async () => {
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.update()
|
|
expect(store.hasPendingTasks).toBe(false)
|
|
})
|
|
|
|
it('lastJobHistoryPriority should return highest job priority', async () => {
|
|
const hist1 = createHistoryJob(10, 'hist-1')
|
|
const hist2 = createHistoryJob(25, 'hist-2')
|
|
const hist3 = createHistoryJob(15, 'hist-3')
|
|
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([hist1, hist2, hist3])
|
|
|
|
await store.update()
|
|
expect(store.lastJobHistoryPriority).toBe(25)
|
|
})
|
|
|
|
it('lastJobHistoryPriority should be -1 when no history', async () => {
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.update()
|
|
expect(store.lastJobHistoryPriority).toBe(-1)
|
|
})
|
|
})
|
|
|
|
describe('clear()', () => {
|
|
beforeEach(async () => {
|
|
mockGetQueue.mockResolvedValue({
|
|
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([])
|
|
|
|
await store.clear()
|
|
|
|
expect(mockClearItems).toHaveBeenCalledTimes(2)
|
|
expect(mockClearItems).toHaveBeenCalledWith('queue')
|
|
expect(mockClearItems).toHaveBeenCalledWith('history')
|
|
expect(store.runningTasks).toHaveLength(0)
|
|
expect(store.pendingTasks).toHaveLength(0)
|
|
expect(store.historyTasks).toHaveLength(0)
|
|
})
|
|
|
|
it('should clear only queue when specified', async () => {
|
|
mockClearItems.mockResolvedValue(undefined)
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([createHistoryJob(3, 'hist-1')])
|
|
|
|
await store.clear(['queue'])
|
|
|
|
expect(mockClearItems).toHaveBeenCalledTimes(1)
|
|
expect(mockClearItems).toHaveBeenCalledWith('queue')
|
|
expect(store.historyTasks).toHaveLength(1)
|
|
})
|
|
|
|
it('should clear only history when specified', async () => {
|
|
mockClearItems.mockResolvedValue(undefined)
|
|
mockGetQueue.mockResolvedValue({
|
|
Running: [createRunningJob(1, 'run-1')],
|
|
Pending: [createPendingJob(2, 'pend-1')]
|
|
})
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.clear(['history'])
|
|
|
|
expect(mockClearItems).toHaveBeenCalledTimes(1)
|
|
expect(mockClearItems).toHaveBeenCalledWith('history')
|
|
expect(store.runningTasks).toHaveLength(1)
|
|
expect(store.pendingTasks).toHaveLength(1)
|
|
})
|
|
|
|
it('should do nothing when empty array passed', async () => {
|
|
await store.clear([])
|
|
|
|
expect(mockClearItems).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('delete()', () => {
|
|
it('should delete task from queue', async () => {
|
|
const job = createPendingJob(1, 'pend-1')
|
|
const task = new TaskItemImpl(job)
|
|
|
|
mockDeleteItem.mockResolvedValue(undefined)
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.delete(task)
|
|
|
|
expect(mockDeleteItem).toHaveBeenCalledWith('queue', 'pend-1')
|
|
})
|
|
|
|
it('should delete task from history', async () => {
|
|
const job = createHistoryJob(1, 'hist-1')
|
|
const task = new TaskItemImpl(job, createTaskOutput())
|
|
|
|
mockDeleteItem.mockResolvedValue(undefined)
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.delete(task)
|
|
|
|
expect(mockDeleteItem).toHaveBeenCalledWith('history', 'hist-1')
|
|
})
|
|
|
|
it('should refresh store after deletion', async () => {
|
|
const job = createPendingJob(1, 'pend-1')
|
|
const task = new TaskItemImpl(job)
|
|
|
|
mockDeleteItem.mockResolvedValue(undefined)
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
await store.delete(task)
|
|
|
|
expect(mockGetQueue).toHaveBeenCalled()
|
|
expect(mockGetHistory).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('update deduplication (coalescing)', () => {
|
|
it('should coalesce concurrent calls — second call does not fire its own request', async () => {
|
|
let resolveQueue!: QueueResolver
|
|
|
|
mockGetQueue.mockImplementation(
|
|
() =>
|
|
new Promise<QueueResponse>((resolve) => {
|
|
resolveQueue = resolve
|
|
})
|
|
)
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
const firstUpdate = store.update()
|
|
const secondUpdate = store.update()
|
|
|
|
// Only one HTTP request should have been made
|
|
expect(mockGetQueue).toHaveBeenCalledTimes(1)
|
|
|
|
// Second call returns immediately (coalesced)
|
|
await secondUpdate
|
|
|
|
// Resolve the in-flight request
|
|
resolveQueue({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
|
|
await firstUpdate
|
|
|
|
expect(store.pendingTasks).toHaveLength(1)
|
|
expect(store.pendingTasks[0].jobId).toBe('new-job')
|
|
|
|
// A re-fetch fires because dirty was set
|
|
expect(mockGetQueue).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('should clear isLoading after in-flight request completes', async () => {
|
|
let resolveQueue!: QueueResolver
|
|
|
|
mockGetQueue.mockImplementation(
|
|
() =>
|
|
new Promise<QueueResponse>((resolve) => {
|
|
resolveQueue = resolve
|
|
})
|
|
)
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
const firstUpdate = store.update()
|
|
expect(store.isLoading).toBe(true)
|
|
|
|
// Second call coalesces and returns immediately
|
|
void store.update()
|
|
|
|
resolveQueue({ Running: [], Pending: [] })
|
|
await firstUpdate
|
|
|
|
// isLoading is true again because re-fetch was triggered
|
|
expect(store.isLoading).toBe(true)
|
|
|
|
// Resolve the re-fetch
|
|
resolveQueue({ Running: [], Pending: [] })
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(store.isLoading).toBe(false)
|
|
})
|
|
|
|
it('should handle in-flight failure and still trigger coalesced re-fetch', async () => {
|
|
let callCount = 0
|
|
let resolveSecond!: QueueResolver
|
|
|
|
mockGetQueue.mockImplementation(() => {
|
|
callCount++
|
|
if (callCount === 1) {
|
|
return Promise.reject(new Error('network error'))
|
|
}
|
|
return new Promise<QueueResponse>((resolve) => {
|
|
resolveSecond = resolve
|
|
})
|
|
})
|
|
mockGetHistory.mockResolvedValue([])
|
|
|
|
const firstUpdate = store.update()
|
|
void store.update() // coalesces, sets dirty
|
|
|
|
// First call resolves (allSettled absorbs the failure) but the dirty
|
|
// flag still triggers a re-fetch when the in-flight request finishes.
|
|
await firstUpdate
|
|
expect(mockGetQueue).toHaveBeenCalledTimes(2)
|
|
|
|
resolveSecond({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(store.pendingTasks).toHaveLength(1)
|
|
expect(store.pendingTasks[0].jobId).toBe('new-job')
|
|
expect(store.isLoading).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('update() partial failures', () => {
|
|
it('reconciles when the queue fetch succeeds, even with an empty snapshot', async () => {
|
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
|
mockGetHistory.mockResolvedValue([])
|
|
const executionStore = useExecutionStore()
|
|
const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
|
|
|
|
await store.update()
|
|
|
|
expect(reconcileSpy).toHaveBeenCalledWith(new Set())
|
|
})
|
|
|
|
it('preserves prior queue state and skips reconcile when the queue fetch fails', async () => {
|
|
mockGetQueue
|
|
.mockResolvedValueOnce({
|
|
Running: [createRunningJob(0, 'run-1')],
|
|
Pending: []
|
|
})
|
|
.mockRejectedValueOnce(new Error('network down'))
|
|
mockGetHistory.mockResolvedValue([])
|
|
const executionStore = useExecutionStore()
|
|
const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
|
|
|
|
await store.update()
|
|
await store.update()
|
|
|
|
// First update reconciles with run-1; second update's queue fetch
|
|
// rejects, so reconcile must not be called again.
|
|
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
|
expect(reconcileSpy).toHaveBeenLastCalledWith(new Set(['run-1']))
|
|
expect(store.runningTasks).toHaveLength(1)
|
|
expect(store.runningTasks[0].jobId).toBe('run-1')
|
|
})
|
|
|
|
it('still updates history when only the queue fetch fails', async () => {
|
|
mockGetQueue.mockRejectedValue(new Error('queue down'))
|
|
mockGetHistory.mockResolvedValue([createHistoryJob(0, 'hist-1')])
|
|
|
|
await store.update()
|
|
|
|
expect(store.historyTasks).toHaveLength(1)
|
|
expect(store.historyTasks[0].jobId).toBe('hist-1')
|
|
})
|
|
|
|
it('still updates queue when only the history fetch fails', async () => {
|
|
mockGetQueue.mockResolvedValue({
|
|
Running: [createRunningJob(0, 'run-1')],
|
|
Pending: []
|
|
})
|
|
mockGetHistory.mockRejectedValue(new Error('history down'))
|
|
|
|
await store.update()
|
|
|
|
expect(store.runningTasks).toHaveLength(1)
|
|
expect(store.runningTasks[0].jobId).toBe('run-1')
|
|
})
|
|
|
|
it('preserves prior state and skips reconcile when both fetches fail', async () => {
|
|
mockGetQueue
|
|
.mockResolvedValueOnce({
|
|
Running: [createRunningJob(0, 'run-1')],
|
|
Pending: []
|
|
})
|
|
.mockRejectedValueOnce(new Error('queue down'))
|
|
mockGetHistory
|
|
.mockResolvedValueOnce([createHistoryJob(0, 'hist-1')])
|
|
.mockRejectedValueOnce(new Error('history down'))
|
|
const executionStore = useExecutionStore()
|
|
const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
|
|
|
|
await store.update()
|
|
await store.update()
|
|
|
|
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
|
expect(store.runningTasks).toHaveLength(1)
|
|
expect(store.runningTasks[0].jobId).toBe('run-1')
|
|
expect(store.historyTasks).toHaveLength(1)
|
|
expect(store.historyTasks[0].jobId).toBe('hist-1')
|
|
expect(store.isLoading).toBe(false)
|
|
})
|
|
})
|
|
})
|