mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 02:02:08 +00:00
## Summary Fixes Zod validation crash when the jobs batch contains text-only preview outputs (e.g. from LLM nodes), which caused the entire Assets sidebar to show nothing. ## Changes - **What**: Made `filename`, `subfolder`, and `type` optional in `zPreviewOutput` and added `.passthrough()` for extra fields like `content`. Text-only jobs are safely filtered out downstream by `supportsPreview`. - Added tests in `fetchJobs.test.ts` verifying a mixed batch (image + text-only + no-preview) parses successfully. - Added test in `assetsStore.test.ts` verifying text-only jobs are skipped without breaking sibling image jobs. Improved `TaskItemImpl` mock to realistically handle media types. ## Review Focus - The `zPreviewOutput` schema now uses `.passthrough()` to allow extra fields from new preview types (like `content` on text previews). This is consistent with `zRawJobListItem` and `zExecutionError` which also use `.passthrough()`. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9724-fix-make-zPreviewOutput-accept-text-only-job-outputs-31f6d73d36508119a7aef99f9b765ecd) by [Unito](https://www.unito.io)
354 lines
9.6 KiB
TypeScript
354 lines
9.6 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
extractWorkflow,
|
|
fetchHistory,
|
|
fetchJobDetail,
|
|
fetchQueue
|
|
} from '@/platform/remote/comfyui/jobs/fetchJobs'
|
|
import type {
|
|
RawJobListItem,
|
|
zJobsListResponse
|
|
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
|
import type { z } from 'zod'
|
|
|
|
type JobsListResponse = z.infer<typeof zJobsListResponse>
|
|
|
|
function createMockJob(
|
|
id: string,
|
|
status: 'pending' | 'in_progress' | 'completed' | 'failed' = 'completed',
|
|
overrides: Partial<RawJobListItem> = {}
|
|
): RawJobListItem {
|
|
return {
|
|
id,
|
|
status,
|
|
create_time: Date.now(),
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function createMockResponse(
|
|
jobs: RawJobListItem[],
|
|
total: number = jobs.length
|
|
): JobsListResponse {
|
|
return {
|
|
jobs,
|
|
pagination: {
|
|
offset: 0,
|
|
limit: 200,
|
|
total,
|
|
has_more: false
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('fetchJobs', () => {
|
|
describe('fetchHistory', () => {
|
|
it('fetches completed jobs', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve(
|
|
createMockResponse([
|
|
createMockJob('job1', 'completed'),
|
|
createMockJob('job2', 'completed')
|
|
])
|
|
)
|
|
})
|
|
|
|
const result = await fetchHistory(mockFetch)
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/jobs?status=completed,failed,cancelled&limit=200&offset=0'
|
|
)
|
|
expect(result).toHaveLength(2)
|
|
expect(result[0].id).toBe('job1')
|
|
expect(result[1].id).toBe('job2')
|
|
})
|
|
|
|
it('assigns synthetic priorities', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve(
|
|
createMockResponse(
|
|
[
|
|
createMockJob('job1', 'completed'),
|
|
createMockJob('job2', 'completed'),
|
|
createMockJob('job3', 'completed')
|
|
],
|
|
3
|
|
)
|
|
)
|
|
})
|
|
|
|
const result = await fetchHistory(mockFetch)
|
|
|
|
// Priority should be assigned from total down
|
|
expect(result[0].priority).toBe(3) // total - 0 - 0
|
|
expect(result[1].priority).toBe(2) // total - 0 - 1
|
|
expect(result[2].priority).toBe(1) // total - 0 - 2
|
|
})
|
|
|
|
it('calculates priority correctly with non-zero offset', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve(
|
|
createMockResponse(
|
|
[
|
|
createMockJob('job4', 'completed'),
|
|
createMockJob('job5', 'completed')
|
|
],
|
|
10 // total of 10 jobs
|
|
)
|
|
)
|
|
})
|
|
|
|
// Fetch page 2 (offset=5)
|
|
const result = await fetchHistory(mockFetch, 200, 5)
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/jobs?status=completed,failed,cancelled&limit=200&offset=5'
|
|
)
|
|
// Priority base is total - offset = 10 - 5 = 5
|
|
expect(result[0].priority).toBe(5) // (total - offset) - 0
|
|
expect(result[1].priority).toBe(4) // (total - offset) - 1
|
|
})
|
|
|
|
it('preserves server-provided priority', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve(
|
|
createMockResponse([
|
|
createMockJob('job1', 'completed', { priority: 999 })
|
|
])
|
|
)
|
|
})
|
|
|
|
const result = await fetchHistory(mockFetch)
|
|
|
|
expect(result[0].priority).toBe(999)
|
|
})
|
|
|
|
it('returns empty array on error', async () => {
|
|
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
|
|
|
const result = await fetchHistory(mockFetch)
|
|
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('returns empty array on non-ok response', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 500
|
|
})
|
|
|
|
const result = await fetchHistory(mockFetch)
|
|
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('parses batch containing text-only preview outputs', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve(
|
|
createMockResponse([
|
|
createMockJob('image-job', 'completed', {
|
|
preview_output: {
|
|
filename: 'output.png',
|
|
subfolder: '',
|
|
type: 'output',
|
|
nodeId: '1',
|
|
mediaType: 'images'
|
|
}
|
|
}),
|
|
createMockJob('text-job', 'completed', {
|
|
preview_output: {
|
|
content: 'some generated text',
|
|
nodeId: '5',
|
|
mediaType: 'text'
|
|
}
|
|
}),
|
|
createMockJob('no-preview-job', 'completed')
|
|
])
|
|
)
|
|
})
|
|
|
|
const result = await fetchHistory(mockFetch)
|
|
|
|
expect(result).toHaveLength(3)
|
|
expect(result[0].id).toBe('image-job')
|
|
expect(result[1].id).toBe('text-job')
|
|
expect(result[2].id).toBe('no-preview-job')
|
|
})
|
|
})
|
|
|
|
describe('fetchQueue', () => {
|
|
it('fetches running and pending jobs', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve(
|
|
createMockResponse([
|
|
createMockJob('running1', 'in_progress'),
|
|
createMockJob('pending1', 'pending'),
|
|
createMockJob('pending2', 'pending')
|
|
])
|
|
)
|
|
})
|
|
|
|
const result = await fetchQueue(mockFetch)
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/jobs?status=in_progress,pending&limit=200&offset=0'
|
|
)
|
|
expect(result.Running).toHaveLength(1)
|
|
expect(result.Pending).toHaveLength(2)
|
|
expect(result.Running[0].id).toBe('running1')
|
|
expect(result.Pending[0].id).toBe('pending1')
|
|
})
|
|
|
|
it('assigns queue priorities above history', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve(
|
|
createMockResponse([
|
|
createMockJob('running1', 'in_progress'),
|
|
createMockJob('pending1', 'pending')
|
|
])
|
|
)
|
|
})
|
|
|
|
const result = await fetchQueue(mockFetch)
|
|
|
|
// Queue priorities should be above 1_000_000 (QUEUE_PRIORITY_BASE)
|
|
expect(result.Running[0].priority).toBeGreaterThan(1_000_000)
|
|
expect(result.Pending[0].priority).toBeGreaterThan(1_000_000)
|
|
// Pending should have higher priority than running
|
|
expect(result.Pending[0].priority).toBeGreaterThan(
|
|
result.Running[0].priority
|
|
)
|
|
})
|
|
|
|
it('returns empty arrays on error', async () => {
|
|
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
|
|
|
const result = await fetchQueue(mockFetch)
|
|
|
|
expect(result).toEqual({ Running: [], Pending: [] })
|
|
})
|
|
})
|
|
|
|
describe('fetchJobDetail', () => {
|
|
it('fetches job detail by id', async () => {
|
|
const jobDetail = {
|
|
...createMockJob('job1', 'completed'),
|
|
workflow: { extra_data: { extra_pnginfo: { workflow: {} } } },
|
|
outputs: {
|
|
'1': {
|
|
images: [{ filename: 'test.png', subfolder: '', type: 'output' }]
|
|
}
|
|
}
|
|
}
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(jobDetail)
|
|
})
|
|
|
|
const result = await fetchJobDetail(mockFetch, 'job1')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith('/jobs/job1')
|
|
expect(result?.id).toBe('job1')
|
|
expect(result?.outputs).toBeDefined()
|
|
})
|
|
|
|
it('returns undefined for non-ok response', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 404
|
|
})
|
|
|
|
const result = await fetchJobDetail(mockFetch, 'nonexistent')
|
|
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
it('returns undefined on error', async () => {
|
|
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
|
|
|
const result = await fetchJobDetail(mockFetch, 'job1')
|
|
|
|
expect(result).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('extractWorkflow', () => {
|
|
const validWorkflow = {
|
|
version: 0.4,
|
|
last_node_id: 1,
|
|
last_link_id: 0,
|
|
nodes: [],
|
|
links: []
|
|
}
|
|
|
|
it('extracts and validates workflow from nested structure', async () => {
|
|
const jobDetail = {
|
|
...createMockJob('job1', 'completed'),
|
|
workflow: {
|
|
extra_data: {
|
|
extra_pnginfo: {
|
|
workflow: validWorkflow
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const workflow = await extractWorkflow(jobDetail)
|
|
|
|
expect(workflow).toEqual(validWorkflow)
|
|
})
|
|
|
|
it('returns undefined if workflow not present', async () => {
|
|
const jobDetail = createMockJob('job1', 'completed')
|
|
|
|
const workflow = await extractWorkflow(jobDetail)
|
|
|
|
expect(workflow).toBeUndefined()
|
|
})
|
|
|
|
it('returns undefined for undefined input', async () => {
|
|
const workflow = await extractWorkflow(undefined)
|
|
|
|
expect(workflow).toBeUndefined()
|
|
})
|
|
|
|
it('returns undefined for invalid workflow and logs warning', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const jobDetail = {
|
|
...createMockJob('job1', 'completed'),
|
|
workflow: {
|
|
extra_data: {
|
|
extra_pnginfo: {
|
|
workflow: { invalid: 'data' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const workflow = await extractWorkflow(jobDetail)
|
|
|
|
expect(workflow).toBeUndefined()
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
'[extractWorkflow] Workflow validation failed:',
|
|
expect.any(String)
|
|
)
|
|
consoleSpy.mockRestore()
|
|
})
|
|
})
|
|
})
|