chore: migrate tests from tests-ui/ to colocate with source files (#7811)

## Summary

Migrates all unit tests from `tests-ui/` to colocate with their source
files in `src/`, improving discoverability and maintainability.

## Changes

- **What**: Relocated all unit tests to be adjacent to the code they
test, following the `<source>.test.ts` naming convention
- **Config**: Updated `vitest.config.ts` to remove `tests-ui` include
pattern and `@tests-ui` alias
- **Docs**: Moved testing documentation to `docs/testing/` with updated
paths and patterns

## Review Focus

- Migration patterns documented in
`temp/plans/migrate-tests-ui-to-src.md`
- Tests use `@/` path aliases instead of relative imports
- Shared fixtures placed in `__fixtures__/` directories

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7811-chore-migrate-tests-from-tests-ui-to-colocate-with-source-files-2da6d73d36508147a4cce85365dee614)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-01-05 16:32:24 -08:00
committed by GitHub
parent 832588c7a9
commit 10feb1fd5b
272 changed files with 483 additions and 1239 deletions

View File

@@ -0,0 +1,380 @@
/**
* @fileoverview Test fixtures for history tests.
*/
import type { HistoryResponseV2 } from '@/platform/remote/comfyui/history/types/historyV2Types'
import type { HistoryTaskItem } from '@/schemas/apiSchema'
/**
* V1 API raw response format (object with prompt IDs as keys)
*/
export const historyV1RawResponse: Record<
string,
Omit<HistoryTaskItem, 'taskType'>
> = {
'complete-item-id': {
prompt: [
24,
'complete-item-id',
{},
{
client_id: 'test-client',
extra_pnginfo: {
workflow: {
id: '44f0c9f9-b5a7-48de-99fc-7e80c1570241',
revision: 0,
last_node_id: 9,
last_link_id: 9,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
}
},
['9']
],
outputs: {
'9': {
images: [
{
filename: 'test.png',
subfolder: '',
type: 'output'
}
]
}
},
status: {
status_str: 'success',
completed: true,
messages: [
[
'execution_start',
{ prompt_id: 'complete-item-id', timestamp: 1234567890 }
],
[
'execution_success',
{ prompt_id: 'complete-item-id', timestamp: 1234567900 }
]
]
},
meta: {
'9': {
node_id: '9',
display_node: '9'
}
}
},
'no-status-id': {
prompt: [
23,
'no-status-id',
{},
{
client_id: 'inference'
},
['10']
],
outputs: {
'10': {
images: []
}
},
status: undefined,
meta: {
'10': {
node_id: '10',
display_node: '10'
}
}
}
}
/**
* V2 response with multiple edge cases:
* - Item 0: Complete with all fields
* - Item 1: Missing optional status field
* - Item 2: Missing optional meta field
* - Item 3: Multiple output nodes
*/
export const historyV2Fixture: HistoryResponseV2 = {
history: [
{
prompt_id: 'complete-item-id',
prompt: {
priority: 24,
prompt_id: 'complete-item-id',
extra_data: {
client_id: 'test-client',
extra_pnginfo: {
workflow: {
id: '44f0c9f9-b5a7-48de-99fc-7e80c1570241',
revision: 0,
last_node_id: 9,
last_link_id: 9,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
}
}
},
outputs: {
'9': {
images: [
{
filename: 'test.png',
subfolder: '',
type: 'output'
}
]
}
},
status: {
status_str: 'success',
completed: true,
messages: [
[
'execution_start',
{ prompt_id: 'complete-item-id', timestamp: 1234567890 }
],
[
'execution_success',
{ prompt_id: 'complete-item-id', timestamp: 1234567900 }
]
]
},
meta: {
'9': {
node_id: '9',
display_node: '9'
}
}
},
{
prompt_id: 'no-status-id',
prompt: {
priority: 23,
prompt_id: 'no-status-id',
extra_data: {
client_id: 'inference'
}
},
outputs: {
'10': {
images: []
}
},
meta: {
'10': {
node_id: '10',
display_node: '10'
}
}
},
{
prompt_id: 'no-meta-id',
prompt: {
priority: 22,
prompt_id: 'no-meta-id',
extra_data: {
client_id: 'web-ui'
}
},
outputs: {
'11': {
audio: []
}
},
status: {
status_str: 'error',
completed: false,
messages: []
}
},
{
prompt_id: 'multi-output-id',
prompt: {
priority: 21,
prompt_id: 'multi-output-id',
extra_data: {
client_id: 'batch-processor'
}
},
outputs: {
'3': {
images: [{ filename: 'img1.png', type: 'output', subfolder: '' }]
},
'9': {
images: [{ filename: 'img2.png', type: 'output', subfolder: '' }]
},
'12': {
video: [{ filename: 'video.mp4', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: []
},
meta: {
'3': { node_id: '3', display_node: '3' },
'9': { node_id: '9', display_node: '9' },
'12': { node_id: '12', display_node: '12' }
}
}
]
}
/**
* Expected V1 transformation of historyV2Fixture
* Priority is now synthetic based on execution_success timestamp:
* - complete-item-id: has timestamp → priority 1 (only one with timestamp)
* - no-status-id: no status → priority 0
* - no-meta-id: empty messages → priority 0
* - multi-output-id: empty messages → priority 0
*/
export const expectedV1Fixture: HistoryTaskItem[] = [
{
taskType: 'History',
prompt: [
1,
'complete-item-id',
{},
{
client_id: 'test-client',
extra_pnginfo: {
workflow: {
id: '44f0c9f9-b5a7-48de-99fc-7e80c1570241',
revision: 0,
last_node_id: 9,
last_link_id: 9,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
}
},
['9']
],
outputs: {
'9': {
images: [
{
filename: 'test.png',
subfolder: '',
type: 'output'
}
]
}
},
status: {
status_str: 'success',
completed: true,
messages: [
[
'execution_start',
{ prompt_id: 'complete-item-id', timestamp: 1234567890 }
],
[
'execution_success',
{ prompt_id: 'complete-item-id', timestamp: 1234567900 }
]
]
},
meta: {
'9': {
node_id: '9',
display_node: '9'
}
}
},
{
taskType: 'History',
prompt: [
0,
'no-status-id',
{},
{
client_id: 'inference'
},
['10']
],
outputs: {
'10': {
images: []
}
},
status: undefined,
meta: {
'10': {
node_id: '10',
display_node: '10'
}
}
},
{
taskType: 'History',
prompt: [
0,
'no-meta-id',
{},
{
client_id: 'web-ui'
},
['11']
],
outputs: {
'11': {
audio: []
}
},
status: {
status_str: 'error',
completed: false,
messages: []
},
meta: undefined
},
{
taskType: 'History',
prompt: [
0,
'multi-output-id',
{},
{
client_id: 'batch-processor'
},
['3', '9', '12']
],
outputs: {
'3': {
images: [{ filename: 'img1.png', type: 'output', subfolder: '' }]
},
'9': {
images: [{ filename: 'img2.png', type: 'output', subfolder: '' }]
},
'12': {
video: [{ filename: 'video.mp4', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: []
},
meta: {
'3': { node_id: '3', display_node: '3' },
'9': { node_id: '9', display_node: '9' },
'12': { node_id: '12', display_node: '12' }
}
}
]

View File

@@ -0,0 +1,434 @@
/**
* @fileoverview Unit tests for V2 to V1 history adapter.
*/
import { describe, expect, it } from 'vitest'
import { mapHistoryV2toHistory } from '@/platform/remote/comfyui/history/adapters/v2ToV1Adapter'
import { zRawHistoryItemV2 } from '@/platform/remote/comfyui/history/types/historyV2Types'
import type { HistoryResponseV2 } from '@/platform/remote/comfyui/history/types/historyV2Types'
import {
expectedV1Fixture,
historyV2Fixture
} from '@/platform/remote/comfyui/history/__fixtures__/historyFixtures'
import type { HistoryTaskItem } from '@/platform/remote/comfyui/history/types/historyV1Types'
const historyV2WithMissingTimestamp: HistoryResponseV2 = {
history: [
{
prompt_id: 'item-timestamp-1000',
prompt: {
priority: 0,
prompt_id: 'item-timestamp-1000',
extra_data: {
client_id: 'test-client'
}
},
outputs: {
'1': {
images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: [
[
'execution_success',
{ prompt_id: 'item-timestamp-1000', timestamp: 1000 }
]
]
}
},
{
prompt_id: 'item-timestamp-2000',
prompt: {
priority: 0,
prompt_id: 'item-timestamp-2000',
extra_data: {
client_id: 'test-client'
}
},
outputs: {
'2': {
images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: [
[
'execution_success',
{ prompt_id: 'item-timestamp-2000', timestamp: 2000 }
]
]
}
},
{
prompt_id: 'item-no-timestamp',
prompt: {
priority: 0,
prompt_id: 'item-no-timestamp',
extra_data: {
client_id: 'test-client'
}
},
outputs: {
'3': {
images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: []
}
}
]
}
const historyV2FiveItemsSorting: HistoryResponseV2 = {
history: [
{
prompt_id: 'item-timestamp-3000',
prompt: {
priority: 0,
prompt_id: 'item-timestamp-3000',
extra_data: { client_id: 'test-client' }
},
outputs: {
'1': {
images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: [
[
'execution_success',
{ prompt_id: 'item-timestamp-3000', timestamp: 3000 }
]
]
}
},
{
prompt_id: 'item-timestamp-1000',
prompt: {
priority: 0,
prompt_id: 'item-timestamp-1000',
extra_data: { client_id: 'test-client' }
},
outputs: {
'2': {
images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: [
[
'execution_success',
{ prompt_id: 'item-timestamp-1000', timestamp: 1000 }
]
]
}
},
{
prompt_id: 'item-timestamp-5000',
prompt: {
priority: 0,
prompt_id: 'item-timestamp-5000',
extra_data: { client_id: 'test-client' }
},
outputs: {
'3': {
images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: [
[
'execution_success',
{ prompt_id: 'item-timestamp-5000', timestamp: 5000 }
]
]
}
},
{
prompt_id: 'item-timestamp-2000',
prompt: {
priority: 0,
prompt_id: 'item-timestamp-2000',
extra_data: { client_id: 'test-client' }
},
outputs: {
'4': {
images: [{ filename: 'test4.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: [
[
'execution_success',
{ prompt_id: 'item-timestamp-2000', timestamp: 2000 }
]
]
}
},
{
prompt_id: 'item-timestamp-4000',
prompt: {
priority: 0,
prompt_id: 'item-timestamp-4000',
extra_data: { client_id: 'test-client' }
},
outputs: {
'5': {
images: [{ filename: 'test5.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: [
[
'execution_success',
{ prompt_id: 'item-timestamp-4000', timestamp: 4000 }
]
]
}
}
]
}
const historyV2MultipleNoTimestamp: HistoryResponseV2 = {
history: [
{
prompt_id: 'item-no-timestamp-1',
prompt: {
priority: 0,
prompt_id: 'item-no-timestamp-1',
extra_data: { client_id: 'test-client' }
},
outputs: {
'1': {
images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: []
}
},
{
prompt_id: 'item-no-timestamp-2',
prompt: {
priority: 0,
prompt_id: 'item-no-timestamp-2',
extra_data: { client_id: 'test-client' }
},
outputs: {
'2': {
images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: []
}
},
{
prompt_id: 'item-no-timestamp-3',
prompt: {
priority: 0,
prompt_id: 'item-no-timestamp-3',
extra_data: { client_id: 'test-client' }
},
outputs: {
'3': {
images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
}
},
status: {
status_str: 'success',
completed: true,
messages: []
}
}
]
}
function findResultByPromptId(
result: HistoryTaskItem[],
promptId: string
): HistoryTaskItem {
const item = result.find((item) => item.prompt[1] === promptId)
if (!item) {
throw new Error(`Expected item with promptId ${promptId} not found`)
}
return item
}
describe('mapHistoryV2toHistory', () => {
describe('fixture validation', () => {
it('should have valid fixture data', () => {
// Validate all items in the fixture to ensure test data is correct
historyV2Fixture.history.forEach((item: unknown) => {
expect(() => zRawHistoryItemV2.parse(item)).not.toThrow()
})
})
})
describe('given a complete V2 history response with edge cases', () => {
const history = mapHistoryV2toHistory(historyV2Fixture)
it('should transform all items to V1 format with correct structure', () => {
expect(history).toEqual(expectedV1Fixture)
})
it('should add taskType "History" to all items', () => {
history.forEach((item) => {
expect(item.taskType).toBe('History')
})
})
it('should transform prompt to V1 tuple [priority, id, {}, extra_data, outputNodeIds]', () => {
const firstItem = history[0]
expect(firstItem.prompt[0]).toBe(1) // Synthetic priority based on timestamp
expect(firstItem.prompt[1]).toBe('complete-item-id')
expect(firstItem.prompt[2]).toEqual({}) // history v2 does not return this data
expect(firstItem.prompt[3]).toMatchObject({ client_id: 'test-client' })
expect(firstItem.prompt[4]).toEqual(['9'])
})
it('should handle missing optional status field', () => {
expect(history[1].prompt[1]).toBe('no-status-id')
expect(history[1].status).toBeUndefined()
})
it('should handle missing optional meta field', () => {
expect(history[2].prompt[1]).toBe('no-meta-id')
expect(history[2].meta).toBeUndefined()
})
it('should derive output node IDs from outputs object keys', () => {
const multiOutputItem = history[3]
expect(multiOutputItem.prompt[4]).toEqual(
expect.arrayContaining(['3', '9', '12'])
)
expect(multiOutputItem.prompt[4]).toHaveLength(3)
})
})
describe('given empty history array', () => {
it('should return empty array', () => {
const emptyResponse: HistoryResponseV2 = { history: [] }
const history = mapHistoryV2toHistory(emptyResponse)
expect(history).toEqual([])
})
})
describe('given empty outputs object', () => {
it('should return empty array for output node IDs', () => {
const v2Response: HistoryResponseV2 = {
history: [
{
prompt_id: 'test-id',
prompt: {
priority: 0,
prompt_id: 'test-id',
extra_data: { client_id: 'test' }
},
outputs: {}
}
]
}
const history = mapHistoryV2toHistory(v2Response)
expect(history[0].prompt[4]).toEqual([])
})
})
describe('given missing client_id', () => {
it('should accept history items without client_id', () => {
const v2Response: HistoryResponseV2 = {
history: [
{
prompt_id: 'test-id',
prompt: {
priority: 0,
prompt_id: 'test-id',
extra_data: {}
},
outputs: {}
}
]
}
const history = mapHistoryV2toHistory(v2Response)
expect(history[0].prompt[3].client_id).toBeUndefined()
})
})
describe('timestamp-based priority assignment', () => {
it('assigns priority 0 to items without execution_success timestamp', () => {
const result = mapHistoryV2toHistory(historyV2WithMissingTimestamp)
expect(result).toHaveLength(3)
const item1000 = findResultByPromptId(result, 'item-timestamp-1000')
const item2000 = findResultByPromptId(result, 'item-timestamp-2000')
const itemNoTimestamp = findResultByPromptId(result, 'item-no-timestamp')
expect(item2000.prompt[0]).toBe(2)
expect(item1000.prompt[0]).toBe(1)
expect(itemNoTimestamp.prompt[0]).toBe(0)
})
it('correctly sorts and assigns priorities for multiple items', () => {
const result = mapHistoryV2toHistory(historyV2FiveItemsSorting)
expect(result).toHaveLength(5)
const item1000 = findResultByPromptId(result, 'item-timestamp-1000')
const item2000 = findResultByPromptId(result, 'item-timestamp-2000')
const item3000 = findResultByPromptId(result, 'item-timestamp-3000')
const item4000 = findResultByPromptId(result, 'item-timestamp-4000')
const item5000 = findResultByPromptId(result, 'item-timestamp-5000')
expect(item5000.prompt[0]).toBe(5)
expect(item4000.prompt[0]).toBe(4)
expect(item3000.prompt[0]).toBe(3)
expect(item2000.prompt[0]).toBe(2)
expect(item1000.prompt[0]).toBe(1)
})
it('assigns priority 0 to all items when multiple items lack timestamps', () => {
const result = mapHistoryV2toHistory(historyV2MultipleNoTimestamp)
expect(result).toHaveLength(3)
const item1 = findResultByPromptId(result, 'item-no-timestamp-1')
const item2 = findResultByPromptId(result, 'item-no-timestamp-2')
const item3 = findResultByPromptId(result, 'item-no-timestamp-3')
expect(item1.prompt[0]).toBe(0)
expect(item2.prompt[0]).toBe(0)
expect(item3.prompt[0]).toBe(0)
})
})
})

View File

@@ -0,0 +1,52 @@
/**
* @fileoverview Unit tests for V1 history fetcher.
*/
import { describe, expect, it, vi } from 'vitest'
import { fetchHistoryV1 } from '@/platform/remote/comfyui/history/fetchers/fetchHistoryV1'
import { historyV1RawResponse } from '@/platform/remote/comfyui/history/__fixtures__/historyFixtures'
describe('fetchHistoryV1', () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => historyV1RawResponse
})
it('should fetch from /history endpoint with default max_items', async () => {
await fetchHistoryV1(mockFetchApi)
expect(mockFetchApi).toHaveBeenCalledWith('/history?max_items=200')
})
it('should fetch with custom max_items parameter', async () => {
await fetchHistoryV1(mockFetchApi, 50)
expect(mockFetchApi).toHaveBeenCalledWith('/history?max_items=50')
})
it('should transform object response to array with taskType and preserve fields', async () => {
const result = await fetchHistoryV1(mockFetchApi)
expect(result.History).toHaveLength(2)
result.History.forEach((item) => {
expect(item.taskType).toBe('History')
})
expect(result.History[0]).toMatchObject({
taskType: 'History',
prompt: [24, 'complete-item-id', {}, expect.any(Object), ['9']],
outputs: expect.any(Object),
status: expect.any(Object),
meta: expect.any(Object)
})
})
it('should handle empty response object', async () => {
const emptyMock = vi.fn().mockResolvedValue({
json: async () => ({})
})
const result = await fetchHistoryV1(emptyMock)
expect(result.History).toEqual([])
})
})

View File

@@ -0,0 +1,41 @@
/**
* @fileoverview Unit tests for V2 history fetcher.
*/
import { describe, expect, it, vi } from 'vitest'
import { fetchHistoryV2 } from '@/platform/remote/comfyui/history/fetchers/fetchHistoryV2'
import {
expectedV1Fixture,
historyV2Fixture
} from '@/platform/remote/comfyui/history/__fixtures__/historyFixtures'
describe('fetchHistoryV2', () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => historyV2Fixture
})
it('should fetch from /history_v2 endpoint with default max_items', async () => {
await fetchHistoryV2(mockFetchApi)
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=200')
})
it('should fetch with custom max_items parameter', async () => {
await fetchHistoryV2(mockFetchApi, 50)
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=50')
})
it('should adapt V2 response to V1-compatible format', async () => {
const result = await fetchHistoryV2(mockFetchApi)
expect(result.History).toEqual(expectedV1Fixture)
expect(result).toHaveProperty('History')
expect(Array.isArray(result.History)).toBe(true)
result.History.forEach((item) => {
expect(item.taskType).toBe('History')
expect(item.prompt).toHaveLength(5)
})
})
})

View File

@@ -0,0 +1,335 @@
/**
* @fileoverview Tests for history reconciliation (V1 and V2)
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reconcileHistory } from '@/platform/remote/comfyui/history/reconciliation'
import type { TaskItem } from '@/schemas/apiSchema'
// Mock distribution types
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: true
}))
function createHistoryItem(promptId: string, queueIndex = 0): TaskItem {
return {
taskType: 'History',
prompt: [queueIndex, promptId, {}, {}, []],
status: { status_str: 'success', completed: true, messages: [] },
outputs: {}
}
}
function getAllPromptIds(result: TaskItem[]): string[] {
return result.map((item) => item.prompt[1])
}
describe('reconcileHistory (V1)', () => {
beforeEach(async () => {
const distTypes = await import('@/platform/distribution/types')
vi.mocked(distTypes).isCloud = false
})
describe('when filtering by queueIndex', () => {
it('should retain items with queueIndex greater than lastKnownQueueIndex', () => {
const serverHistory = [
createHistoryItem('new-1', 11),
createHistoryItem('new-2', 10),
createHistoryItem('old', 5)
]
const clientHistory = [createHistoryItem('old', 5)]
const result = reconcileHistory(serverHistory, clientHistory, 10, 9)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(3)
expect(promptIds).toContain('new-1')
expect(promptIds).toContain('new-2')
expect(promptIds).toContain('old')
})
it('should evict items with queueIndex less than or equal to lastKnownQueueIndex', () => {
const serverHistory = [
createHistoryItem('new', 11),
createHistoryItem('existing', 10),
createHistoryItem('old-should-not-appear', 5)
]
const clientHistory = [createHistoryItem('existing', 10)]
const result = reconcileHistory(serverHistory, clientHistory, 10, 10)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(2)
expect(promptIds).toContain('new')
expect(promptIds).toContain('existing')
expect(promptIds).not.toContain('old-should-not-appear')
})
it('should retain all server items when lastKnownQueueIndex is undefined', () => {
const serverHistory = [
createHistoryItem('item-1', 5),
createHistoryItem('item-2', 4)
]
const result = reconcileHistory(serverHistory, [], 10, undefined)
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('item-1')
expect(result[1].prompt[1]).toBe('item-2')
})
})
describe('when reconciling with existing client items', () => {
it('should retain client items that still exist on server', () => {
const serverHistory = [
createHistoryItem('new', 11),
createHistoryItem('existing-1', 9),
createHistoryItem('existing-2', 8)
]
const clientHistory = [
createHistoryItem('existing-1', 9),
createHistoryItem('existing-2', 8)
]
const result = reconcileHistory(serverHistory, clientHistory, 10, 10)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(3)
expect(promptIds).toContain('new')
expect(promptIds).toContain('existing-1')
expect(promptIds).toContain('existing-2')
})
it('should evict client items that no longer exist on server', () => {
const serverHistory = [
createHistoryItem('new', 11),
createHistoryItem('keep', 9)
]
const clientHistory = [
createHistoryItem('keep', 9),
createHistoryItem('removed-from-server', 8)
]
const result = reconcileHistory(serverHistory, clientHistory, 10, 10)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(2)
expect(promptIds).toContain('new')
expect(promptIds).toContain('keep')
expect(promptIds).not.toContain('removed-from-server')
})
})
describe('when limiting the result count', () => {
it('should respect the maxItems constraint', () => {
const serverHistory = Array.from({ length: 10 }, (_, i) =>
createHistoryItem(`item-${i}`, 20 + i)
)
const result = reconcileHistory(serverHistory, [], 5, 15)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(5)
})
it('should evict lowest priority items when exceeding capacity', () => {
const serverHistory = [
createHistoryItem('new-1', 13),
createHistoryItem('new-2', 12),
createHistoryItem('new-3', 11),
createHistoryItem('existing', 9)
]
const clientHistory = [createHistoryItem('existing', 9)]
const result = reconcileHistory(serverHistory, clientHistory, 2, 10)
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('new-1')
expect(result[1].prompt[1]).toBe('new-2')
})
})
describe('when handling empty collections', () => {
it('should return all server items when client history is empty', () => {
const serverHistory = [
createHistoryItem('item-1', 10),
createHistoryItem('item-2', 9)
]
const result = reconcileHistory(serverHistory, [], 10, 8)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(2)
})
it('should return empty result when server history is empty', () => {
const clientHistory = [createHistoryItem('item-1', 5)]
const result = reconcileHistory([], clientHistory, 10, 5)
expect(result).toHaveLength(0)
})
it('should return empty result when both collections are empty', () => {
const result = reconcileHistory([], [], 10, undefined)
expect(result).toHaveLength(0)
})
})
})
describe('reconcileHistory (V2/Cloud)', () => {
beforeEach(async () => {
const distTypes = await import('@/platform/distribution/types')
vi.mocked(distTypes).isCloud = true
})
describe('when adding new items from server', () => {
it('should retain items with promptIds not present in client history', () => {
const serverHistory = [
createHistoryItem('new-item'),
createHistoryItem('existing-item')
]
const clientHistory = [createHistoryItem('existing-item')]
const result = reconcileHistory(serverHistory, clientHistory, 10)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(2)
expect(promptIds).toContain('new-item')
expect(promptIds).toContain('existing-item')
})
it('should respect priority ordering when retaining multiple new items', () => {
const serverHistory = [
createHistoryItem('new-1'),
createHistoryItem('new-2'),
createHistoryItem('existing')
]
const clientHistory = [createHistoryItem('existing')]
const result = reconcileHistory(serverHistory, clientHistory, 10)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(3)
expect(promptIds).toContain('new-1')
expect(promptIds).toContain('new-2')
expect(promptIds).toContain('existing')
})
})
describe('when reconciling with existing client items', () => {
it('should retain client items that still exist on server', () => {
const serverHistory = [
createHistoryItem('item-1'),
createHistoryItem('item-2')
]
const clientHistory = [
createHistoryItem('item-1'),
createHistoryItem('item-2')
]
const result = reconcileHistory(serverHistory, clientHistory, 10)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(2)
expect(promptIds).toContain('item-1')
expect(promptIds).toContain('item-2')
})
it('should evict client items that no longer exist on server', () => {
const serverHistory = [createHistoryItem('item-1')]
const clientHistory = [
createHistoryItem('item-1'),
createHistoryItem('old-item')
]
const result = reconcileHistory(serverHistory, clientHistory, 10)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(1)
expect(promptIds).toContain('item-1')
expect(promptIds).not.toContain('old-item')
})
})
describe('when detecting new items by promptId', () => {
it('should retain new items regardless of queueIndex values', () => {
const serverHistory = [
createHistoryItem('existing', 100),
createHistoryItem('new-item', 50)
]
const clientHistory = [createHistoryItem('existing', 100)]
const result = reconcileHistory(serverHistory, clientHistory, 10)
const promptIds = getAllPromptIds(result)
expect(promptIds).toContain('new-item')
expect(promptIds).toContain('existing')
})
})
describe('when limiting the result count', () => {
it('should respect the maxItems constraint', () => {
const serverHistory = Array.from({ length: 10 }, (_, i) =>
createHistoryItem(`server-${i}`)
)
const clientHistory = Array.from({ length: 5 }, (_, i) =>
createHistoryItem(`client-${i}`)
)
const result = reconcileHistory(serverHistory, clientHistory, 5)
const promptIds = getAllPromptIds(result)
expect(promptIds).toHaveLength(5)
})
it('should evict lowest priority items when exceeding capacity', () => {
const serverHistory = [
createHistoryItem('new-1'),
createHistoryItem('new-2'),
createHistoryItem('existing')
]
const clientHistory = [createHistoryItem('existing')]
const result = reconcileHistory(serverHistory, clientHistory, 2)
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('new-1')
expect(result[1].prompt[1]).toBe('new-2')
})
})
describe('when handling empty collections', () => {
it('should return all server items when client history is empty', () => {
const serverHistory = [
createHistoryItem('item-1'),
createHistoryItem('item-2')
]
const result = reconcileHistory(serverHistory, [], 10)
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('item-1')
expect(result[1].prompt[1]).toBe('item-2')
})
it('should return empty result when server history is empty', () => {
const clientHistory = [
createHistoryItem('item-1'),
createHistoryItem('item-2')
]
const result = reconcileHistory([], clientHistory, 10)
expect(result).toHaveLength(0)
})
it('should return empty result when both collections are empty', () => {
const result = reconcileHistory([], [], 10)
expect(result).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,291 @@
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()
})
})
})