mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-11 16:10:05 +00:00
## Summary Add Jobs API infrastructure in preparation for migrating from legacy `/history`, `/history_v2`, and `/queue` endpoints to the unified `/jobs` API. **This is PR 1 of 3** - Additive changes only, no breaking changes. ## Changes - **What**: - Add Zod schemas for runtime validation of Jobs API responses (`JobListItem`, `JobDetail`) - Add `fetchQueue`, `fetchHistory`, `fetchJobDetail` fetchers for `/jobs` endpoint - Add `extractWorkflow` utility for extracting workflow from nested job detail response - Add synthetic priority assignment for queue ordering (pending > running > history) - Add comprehensive tests for all new fetchers - **Non-breaking**: All changes are additive - existing code continues to work ## Review Focus 1. **Zod schema flexibility**: Using `.passthrough()` to allow extra API fields - ensures forward compatibility but less strict validation 2. **Priority computation**: Synthetic priority ensures display order: pending (queued) → running → completed (history) 3. **Test coverage**: Verify tests adequately cover edge cases ## Files Added - `src/platform/remote/comfyui/jobs/` - New Jobs API module - `types/jobTypes.ts` - Zod schemas and TypeScript types - `fetchers/fetchJobs.ts` - API fetchers with validation - `index.ts` - Barrel exports - `tests-ui/tests/platform/remote/comfyui/jobs/fetchers/fetchJobs.test.ts` - Tests ## Next PRs - **PR 2**: Migrate `getQueue()` and `getHistory()` to use Jobs API - **PR 3**: Remove legacy history code and unused types ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7169-feat-Add-Jobs-API-infrastructure-PR-1-of-3-2bf6d73d3650812eae4ac0555a86969c) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude <noreply@anthropic.com>
292 lines
7.8 KiB
TypeScript
292 lines
7.8 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' = 'completed',
|
|
overrides: Partial<RawJobListItem> = {}
|
|
): RawJobListItem {
|
|
return {
|
|
id,
|
|
status,
|
|
create_time: Date.now(),
|
|
execution_start_time: null,
|
|
execution_end_time: null,
|
|
preview_output: null,
|
|
outputs_count: 0,
|
|
...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&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&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([])
|
|
})
|
|
})
|
|
|
|
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', () => {
|
|
it('extracts workflow from nested structure', () => {
|
|
const jobDetail = {
|
|
...createMockJob('job1', 'completed'),
|
|
workflow: {
|
|
extra_data: {
|
|
extra_pnginfo: {
|
|
workflow: { nodes: [], links: [] }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const workflow = extractWorkflow(jobDetail)
|
|
|
|
expect(workflow).toEqual({ nodes: [], links: [] })
|
|
})
|
|
|
|
it('returns undefined if workflow not present', () => {
|
|
const jobDetail = createMockJob('job1', 'completed')
|
|
|
|
const workflow = extractWorkflow(jobDetail)
|
|
|
|
expect(workflow).toBeUndefined()
|
|
})
|
|
|
|
it('returns undefined for undefined input', () => {
|
|
const workflow = extractWorkflow(undefined)
|
|
|
|
expect(workflow).toBeUndefined()
|
|
})
|
|
})
|
|
})
|