mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
feat(api): add history_v2 for cloud outputs (#6288)
## Summary Backport outputs from new cloud history endpoint Does: 1. Show history in the Queue 2. Show outputs from prompt execution Does not: 1. Handle appending latest images generated to queue history 2. Making sure that workflow data from images is available from load (requires additional API call to fetch) Most of this PR is: 1. Test fixtures (truncated workflow to test). 2. The service worker so I could verify my changes locally. ## Changes - Add `history_v2` to `history` adapter - Add tests for mapping - Do branded validation for promptIds (suggestion from @DrJKL) - Create a dev environment service worker so we can view cloud hosted images in development. ## Review Focus 1. Is the dev-only service work the right way to do it? It was the easiest I could think of. 4. Are the validation changes too heavy? I can rip them out if needed. ## Screenshots 🎃 https://github.com/user-attachments/assets/1787485a-8d27-4abe-abc8-cf133c1a52aa ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6288-Feat-history-v2-outputs-2976d73d365081a99864c40343449dcd) by [Unito](https://www.unito.io) --------- Co-authored-by: bymyself <cbyrne@comfy.org>
This commit is contained in:
@@ -60,6 +60,7 @@ export default defineConfig([
|
||||
'**/vite.config.*.timestamp*',
|
||||
'**/vitest.config.*.timestamp*',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
'public/auth-dev-sw.js',
|
||||
'public/auth-sw.js',
|
||||
'src/extensions/core/*',
|
||||
'src/scripts/*',
|
||||
|
||||
@@ -42,8 +42,9 @@ const config: KnipConfig = {
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Service worker - registered at runtime via navigator.serviceWorker.register()
|
||||
'public/auth-sw.js'
|
||||
// Service workers - registered at runtime via navigator.serviceWorker.register()
|
||||
'public/auth-sw.js',
|
||||
'public/auth-dev-sw.js'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
168
public/auth-dev-sw.js
Normal file
168
public/auth-dev-sw.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @fileoverview Authentication Service Worker (Development Version)
|
||||
* Intercepts /api/view requests and rewrites them to a configurable base URL with auth token.
|
||||
* Required for browser-native requests (img, video, audio) that cannot send custom headers.
|
||||
* This version is used in development to proxy requests to staging/test environments.
|
||||
* Default base URL: https://testcloud.comfy.org (configurable via SET_BASE_URL message)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AuthHeader
|
||||
* @property {string} Authorization - Bearer token for authentication
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CachedAuth
|
||||
* @property {AuthHeader|null} header
|
||||
* @property {number} expiresAt - Timestamp when cache expires
|
||||
*/
|
||||
|
||||
const CACHE_TTL_MS = 50 * 60 * 1000 // 50 minutes (Firebase tokens expire in 1 hour)
|
||||
|
||||
/** @type {CachedAuth|null} */
|
||||
let authCache = null
|
||||
|
||||
/** @type {Promise<AuthHeader|null>|null} */
|
||||
let authRequestInFlight = null
|
||||
|
||||
/** @type {string} */
|
||||
let baseUrl = 'https://testcloud.comfy.org'
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'INVALIDATE_AUTH_HEADER') {
|
||||
authCache = null
|
||||
authRequestInFlight = null
|
||||
}
|
||||
|
||||
if (event.data.type === 'SET_BASE_URL') {
|
||||
baseUrl = event.data.baseUrl
|
||||
console.log('[Auth DEV SW] Base URL set to:', baseUrl)
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
if (
|
||||
!url.pathname.startsWith('/api/view') &&
|
||||
!url.pathname.startsWith('/api/viewvideo')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
// Rewrite URL to use configured base URL (default: stagingcloud.comfy.org)
|
||||
const originalUrl = new URL(event.request.url)
|
||||
const rewrittenUrl = new URL(
|
||||
originalUrl.pathname + originalUrl.search,
|
||||
baseUrl
|
||||
)
|
||||
|
||||
const authHeader = await getAuthHeader()
|
||||
|
||||
// With mode: 'no-cors', Authorization headers are stripped by the browser
|
||||
// So we add the token to the URL as a query parameter instead
|
||||
if (authHeader && authHeader.Authorization) {
|
||||
const token = authHeader.Authorization.replace('Bearer ', '')
|
||||
rewrittenUrl.searchParams.set('token', token)
|
||||
}
|
||||
|
||||
// Cross-origin request requires no-cors mode
|
||||
// - mode: 'no-cors' allows cross-origin fetches without CORS headers
|
||||
// - Returns opaque response, which works fine for images/videos/audio
|
||||
// - Auth token is sent via query parameter since headers are stripped in no-cors mode
|
||||
// - Server may return redirect to GCS, which will be followed automatically
|
||||
return fetch(rewrittenUrl, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
mode: 'no-cors'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Auth DEV SW] Request failed:', error)
|
||||
const originalUrl = new URL(event.request.url)
|
||||
const rewrittenUrl = new URL(
|
||||
originalUrl.pathname + originalUrl.search,
|
||||
baseUrl
|
||||
)
|
||||
return fetch(rewrittenUrl, {
|
||||
mode: 'no-cors',
|
||||
redirect: 'follow'
|
||||
})
|
||||
}
|
||||
})()
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Gets auth header from cache or requests from main thread
|
||||
* @returns {Promise<AuthHeader|null>}
|
||||
*/
|
||||
async function getAuthHeader() {
|
||||
// Return cached value if valid
|
||||
if (authCache && authCache.expiresAt > Date.now()) {
|
||||
return authCache.header
|
||||
}
|
||||
|
||||
// Clear expired cache
|
||||
if (authCache) {
|
||||
authCache = null
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests
|
||||
if (authRequestInFlight) {
|
||||
return authRequestInFlight
|
||||
}
|
||||
|
||||
authRequestInFlight = requestAuthHeaderFromMainThread()
|
||||
const header = await authRequestInFlight
|
||||
authRequestInFlight = null
|
||||
|
||||
// Cache the result
|
||||
if (header) {
|
||||
authCache = {
|
||||
header,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS
|
||||
}
|
||||
}
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests auth header from main thread via MessageChannel
|
||||
* @returns {Promise<AuthHeader|null>}
|
||||
*/
|
||||
async function requestAuthHeaderFromMainThread() {
|
||||
const clients = await self.clients.matchAll()
|
||||
if (clients.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const messageChannel = new MessageChannel()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timeoutId
|
||||
|
||||
messageChannel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve(event.data.authHeader)
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
console.error(
|
||||
'[Auth DEV SW] Timeout waiting for auth header from main thread'
|
||||
)
|
||||
resolve(null)
|
||||
}, 1000)
|
||||
|
||||
clients[0].postMessage({ type: 'REQUEST_AUTH_HEADER' }, [
|
||||
messageChannel.port2
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
@@ -42,7 +42,6 @@ const showContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
|
||||
|
||||
if (isElectron()) {
|
||||
|
||||
@@ -13,7 +13,34 @@ async function registerAuthServiceWorker(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.serviceWorker.register('/auth-sw.js')
|
||||
// Use dev service worker in development mode (rewrites to configured backend URL with token in query param)
|
||||
// Use production service worker in production (same-origin requests with Authorization header)
|
||||
const swPath = import.meta.env.DEV ? '/auth-dev-sw.js' : '/auth-sw.js'
|
||||
const registration = await navigator.serviceWorker.register(swPath)
|
||||
|
||||
// Configure base URL for dev service worker
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth DEV SW] Registering development serviceworker')
|
||||
// Use the same URL that Vite proxy is using
|
||||
const baseUrl = __DEV_SERVER_COMFYUI_URL__
|
||||
navigator.serviceWorker.controller?.postMessage({
|
||||
type: 'SET_BASE_URL',
|
||||
baseUrl
|
||||
})
|
||||
|
||||
// Also set base URL when service worker becomes active
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing
|
||||
newWorker?.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'activated') {
|
||||
navigator.serviceWorker.controller?.postMessage({
|
||||
type: 'SET_BASE_URL',
|
||||
baseUrl
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
setupAuthHeaderProvider()
|
||||
setupCacheInvalidation()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @fileoverview Adapter to convert V2 history format to V1 format
|
||||
* @module platform/remote/comfyui/history/adapters/v2ToV1Adapter
|
||||
*
|
||||
* Converts cloud API V2 response format to the V1 format expected by the app.
|
||||
*/
|
||||
|
||||
import type { HistoryTaskItem, TaskPrompt } from '../types/historyV1Types'
|
||||
import type {
|
||||
HistoryResponseV2,
|
||||
RawHistoryItemV2,
|
||||
TaskOutput,
|
||||
TaskPromptV2
|
||||
} from '../types/historyV2Types'
|
||||
|
||||
/**
|
||||
* Maps V2 prompt format to V1 prompt tuple format.
|
||||
*/
|
||||
function mapPromptV2toV1(
|
||||
promptV2: TaskPromptV2,
|
||||
outputs: TaskOutput
|
||||
): TaskPrompt {
|
||||
const outputNodesIds = Object.keys(outputs)
|
||||
const { priority, prompt_id, extra_data } = promptV2
|
||||
return [priority, prompt_id, {}, extra_data, outputNodesIds]
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps V2 history format to V1 history format.
|
||||
*/
|
||||
export function mapHistoryV2toHistory(
|
||||
historyV2Response: HistoryResponseV2
|
||||
): HistoryTaskItem[] {
|
||||
return historyV2Response.history.map(
|
||||
({ prompt, status, outputs, meta }: RawHistoryItemV2): HistoryTaskItem => ({
|
||||
taskType: 'History' as const,
|
||||
prompt: mapPromptV2toV1(prompt, outputs),
|
||||
status,
|
||||
outputs,
|
||||
meta
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @fileoverview V1 History Fetcher - Desktop/localhost API
|
||||
* @module platform/remote/comfyui/history/fetchers/fetchHistoryV1
|
||||
*
|
||||
* Fetches history directly from V1 API endpoint.
|
||||
* Used by desktop and localhost distributions.
|
||||
*/
|
||||
|
||||
import type {
|
||||
HistoryTaskItem,
|
||||
HistoryV1Response
|
||||
} from '../types/historyV1Types'
|
||||
|
||||
/**
|
||||
* Fetches history from V1 API endpoint
|
||||
* @param api - API instance with fetchApi method
|
||||
* @param maxItems - Maximum number of history items to fetch
|
||||
* @returns Promise resolving to V1 history response
|
||||
*/
|
||||
export async function fetchHistoryV1(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
maxItems: number = 200
|
||||
): Promise<HistoryV1Response> {
|
||||
const res = await fetchApi(`/history?max_items=${maxItems}`)
|
||||
const json: Record<
|
||||
string,
|
||||
Omit<HistoryTaskItem, 'taskType'>
|
||||
> = await res.json()
|
||||
|
||||
return {
|
||||
History: Object.values(json).map((item) => ({
|
||||
...item,
|
||||
taskType: 'History'
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @fileoverview V2 History Fetcher - Cloud API with adapter
|
||||
* @module platform/remote/comfyui/history/fetchers/fetchHistoryV2
|
||||
*
|
||||
* Fetches history from V2 API endpoint and converts to V1 format.
|
||||
* Used exclusively by cloud distribution.
|
||||
*/
|
||||
|
||||
import { mapHistoryV2toHistory } from '../adapters/v2ToV1Adapter'
|
||||
import type { HistoryV1Response } from '../types/historyV1Types'
|
||||
import type { HistoryResponseV2 } from '../types/historyV2Types'
|
||||
|
||||
/**
|
||||
* Fetches history from V2 API endpoint and adapts to V1 format
|
||||
* @param fetchApi - API instance with fetchApi method
|
||||
* @param maxItems - Maximum number of history items to fetch
|
||||
* @returns Promise resolving to V1 history response (adapted from V2)
|
||||
*/
|
||||
export async function fetchHistoryV2(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
maxItems: number = 200
|
||||
): Promise<HistoryV1Response> {
|
||||
const res = await fetchApi(`/history_v2?max_items=${maxItems}`)
|
||||
const rawData: HistoryResponseV2 = await res.json()
|
||||
const adaptedHistory = mapHistoryV2toHistory(rawData)
|
||||
return { History: adaptedHistory }
|
||||
}
|
||||
29
src/platform/remote/comfyui/history/index.ts
Normal file
29
src/platform/remote/comfyui/history/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @fileoverview History API module - Distribution-aware exports
|
||||
* @module platform/remote/comfyui/history
|
||||
*
|
||||
* This module provides a unified history fetching interface that automatically
|
||||
* uses the correct implementation based on build-time distribution constant.
|
||||
*
|
||||
* - Cloud builds: Uses V2 API with adapter (tree-shakes V1 fetcher)
|
||||
* - Desktop/localhost builds: Uses V1 API directly (tree-shakes V2 fetcher + adapter)
|
||||
*
|
||||
* The rest of the application only needs to import from this module and use
|
||||
* V1 types - all distribution-specific details are encapsulated here.
|
||||
*/
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { fetchHistoryV1 } from './fetchers/fetchHistoryV1'
|
||||
import { fetchHistoryV2 } from './fetchers/fetchHistoryV2'
|
||||
|
||||
/**
|
||||
* Fetches history using the appropriate API for the current distribution.
|
||||
* Build-time constant enables dead code elimination - only one implementation
|
||||
* will be included in the final bundle.
|
||||
*/
|
||||
export const fetchHistory = isCloud ? fetchHistoryV2 : fetchHistoryV1
|
||||
|
||||
/**
|
||||
* Export only V1 types publicly - consumers don't need to know about V2
|
||||
*/
|
||||
export type * from './types'
|
||||
15
src/platform/remote/comfyui/history/types/historyV1Types.ts
Normal file
15
src/platform/remote/comfyui/history/types/historyV1Types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @fileoverview History V1 types - Public interface used throughout the app
|
||||
* @module platform/remote/comfyui/history/types/historyV1Types
|
||||
*
|
||||
* These types represent the V1 history format that the application expects.
|
||||
* Both desktop (direct V1 API) and cloud (V2 API + adapter) return data in this format.
|
||||
*/
|
||||
|
||||
import type { HistoryTaskItem, TaskPrompt } from '@/schemas/apiSchema'
|
||||
|
||||
export interface HistoryV1Response {
|
||||
History: HistoryTaskItem[]
|
||||
}
|
||||
|
||||
export type { HistoryTaskItem, TaskPrompt }
|
||||
45
src/platform/remote/comfyui/history/types/historyV2Types.ts
Normal file
45
src/platform/remote/comfyui/history/types/historyV2Types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @fileoverview History V2 types and schemas - Internal cloud API format
|
||||
* @module platform/remote/comfyui/history/types/historyV2Types
|
||||
*
|
||||
* These types and schemas represent the V2 history format returned by the cloud API.
|
||||
* They are only used internally and are converted to V1 format via adapter.
|
||||
*
|
||||
* IMPORTANT: These types should NOT be used outside this history module.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
zExtraData,
|
||||
zPromptId,
|
||||
zQueueIndex,
|
||||
zStatus,
|
||||
zTaskMeta,
|
||||
zTaskOutput
|
||||
} from '@/schemas/apiSchema'
|
||||
|
||||
const zTaskPromptV2 = z.object({
|
||||
priority: zQueueIndex,
|
||||
prompt_id: zPromptId,
|
||||
extra_data: zExtraData
|
||||
})
|
||||
|
||||
const zRawHistoryItemV2 = z.object({
|
||||
prompt_id: zPromptId,
|
||||
prompt: zTaskPromptV2,
|
||||
status: zStatus.optional(),
|
||||
outputs: zTaskOutput,
|
||||
meta: zTaskMeta.optional()
|
||||
})
|
||||
|
||||
const zHistoryResponseV2 = z.object({
|
||||
history: z.array(zRawHistoryItemV2)
|
||||
})
|
||||
|
||||
export type TaskPromptV2 = z.infer<typeof zTaskPromptV2>
|
||||
export type RawHistoryItemV2 = z.infer<typeof zRawHistoryItemV2>
|
||||
export type HistoryResponseV2 = z.infer<typeof zHistoryResponseV2>
|
||||
export type TaskOutput = z.infer<typeof zTaskOutput>
|
||||
|
||||
export { zRawHistoryItemV2 }
|
||||
9
src/platform/remote/comfyui/history/types/index.ts
Normal file
9
src/platform/remote/comfyui/history/types/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Public history types export
|
||||
* @module platform/remote/comfyui/history/types
|
||||
*
|
||||
* Only V1 types are exported publicly - the rest of the app
|
||||
* should never need to know about V2 types or implementation details.
|
||||
*/
|
||||
|
||||
export type * from './historyV1Types'
|
||||
@@ -11,8 +11,8 @@ import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
|
||||
const zNodeType = z.string()
|
||||
const zQueueIndex = z.number()
|
||||
const zPromptId = z.string()
|
||||
export const zQueueIndex = z.number()
|
||||
export const zPromptId = z.string()
|
||||
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
||||
export type ResultItemType = z.infer<typeof resultItemType>
|
||||
|
||||
@@ -170,10 +170,10 @@ const zExtraPngInfo = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zExtraData = z.object({
|
||||
export const zExtraData = z.object({
|
||||
/** extra_pnginfo can be missing is backend execution gets a validation error. */
|
||||
extra_pnginfo: zExtraPngInfo.optional(),
|
||||
client_id: z.string()
|
||||
client_id: z.string().optional()
|
||||
})
|
||||
const zOutputsToExecute = z.array(zNodeId)
|
||||
|
||||
@@ -210,7 +210,7 @@ const zStatusMessage = z.union([
|
||||
zExecutionErrorMessage
|
||||
])
|
||||
|
||||
const zStatus = z.object({
|
||||
export const zStatus = z.object({
|
||||
status_str: z.enum(['success', 'error']),
|
||||
completed: z.boolean(),
|
||||
messages: z.array(zStatusMessage)
|
||||
@@ -239,7 +239,7 @@ const zPendingTaskItem = z.object({
|
||||
prompt: zTaskPrompt
|
||||
})
|
||||
|
||||
const zTaskOutput = z.record(zNodeId, zOutputs)
|
||||
export const zTaskOutput = z.record(zNodeId, zOutputs)
|
||||
|
||||
const zNodeOutputsMeta = z.object({
|
||||
node_id: zNodeId,
|
||||
@@ -248,7 +248,7 @@ const zNodeOutputsMeta = z.object({
|
||||
read_node_id: zNodeId.optional()
|
||||
})
|
||||
|
||||
const zTaskMeta = z.record(zNodeId, zNodeOutputsMeta)
|
||||
export const zTaskMeta = z.record(zNodeId, zNodeOutputsMeta)
|
||||
|
||||
const zHistoryTaskItem = z.object({
|
||||
taskType: z.literal('History'),
|
||||
|
||||
@@ -47,6 +47,7 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { fetchHistory } from '@/platform/remote/comfyui/history'
|
||||
|
||||
interface QueuePromptRequestBody {
|
||||
client_id: string
|
||||
@@ -900,14 +901,7 @@ export class ComfyApi extends EventTarget {
|
||||
max_items: number = 200
|
||||
): Promise<{ History: HistoryTaskItem[] }> {
|
||||
try {
|
||||
const res = await this.fetchApi(`/history?max_items=${max_items}`)
|
||||
const json: Promise<HistoryTaskItem[]> = await res.json()
|
||||
return {
|
||||
History: Object.values(json).map((item) => ({
|
||||
...item,
|
||||
taskType: 'History'
|
||||
}))
|
||||
}
|
||||
return await fetchHistory(this.fetchApi.bind(this), max_items)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return { History: [] }
|
||||
|
||||
4
src/vite-env.d.ts
vendored
4
src/vite-env.d.ts
vendored
@@ -16,4 +16,8 @@ declare global {
|
||||
interface Window {
|
||||
__COMFYUI_FRONTEND_VERSION__: string
|
||||
}
|
||||
|
||||
const __DEV_SERVER_COMFYUI_URL__: string
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
375
tests-ui/fixtures/historyFixtures.ts
Normal file
375
tests-ui/fixtures/historyFixtures.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
export const expectedV1Fixture: HistoryTaskItem[] = [
|
||||
{
|
||||
taskType: 'History',
|
||||
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'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
taskType: 'History',
|
||||
prompt: [
|
||||
23,
|
||||
'no-status-id',
|
||||
{},
|
||||
{
|
||||
client_id: 'inference'
|
||||
},
|
||||
['10']
|
||||
],
|
||||
outputs: {
|
||||
'10': {
|
||||
images: []
|
||||
}
|
||||
},
|
||||
status: undefined,
|
||||
meta: {
|
||||
'10': {
|
||||
node_id: '10',
|
||||
display_node: '10'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
taskType: 'History',
|
||||
prompt: [
|
||||
22,
|
||||
'no-meta-id',
|
||||
{},
|
||||
{
|
||||
client_id: 'web-ui'
|
||||
},
|
||||
['11']
|
||||
],
|
||||
outputs: {
|
||||
'11': {
|
||||
audio: []
|
||||
}
|
||||
},
|
||||
status: {
|
||||
status_str: 'error',
|
||||
completed: false,
|
||||
messages: []
|
||||
},
|
||||
meta: undefined
|
||||
},
|
||||
{
|
||||
taskType: 'History',
|
||||
prompt: [
|
||||
21,
|
||||
'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' }
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @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 '@tests-ui/fixtures/historyFixtures'
|
||||
|
||||
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(24)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 '@tests-ui/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([])
|
||||
})
|
||||
})
|
||||
@@ -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 '@tests-ui/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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,9 @@
|
||||
],
|
||||
"@/utils/networkUtil": [
|
||||
"packages/shared-frontend-utils/src/networkUtil.ts"
|
||||
],
|
||||
"@tests-ui/*": [
|
||||
"tests-ui/*"
|
||||
]
|
||||
},
|
||||
"typeRoots": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import dotenv from 'dotenv'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
@@ -13,7 +13,7 @@ import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
|
||||
|
||||
dotenv.config()
|
||||
dotenvConfig()
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === 'development'
|
||||
const SHOULD_MINIFY = process.env.ENABLE_MINIFY === 'true'
|
||||
@@ -302,7 +302,8 @@ export default defineConfig({
|
||||
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
|
||||
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
|
||||
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
|
||||
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION)
|
||||
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
|
||||
__DEV_SERVER_COMFYUI_URL__: JSON.stringify(DEV_SERVER_COMFYUI_URL)
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
||||
'@/utils/formatUtil': '/packages/shared-frontend-utils/src/formatUtil.ts',
|
||||
'@/utils/networkUtil':
|
||||
'/packages/shared-frontend-utils/src/networkUtil.ts',
|
||||
'@tests-ui': '/tests-ui',
|
||||
'@': '/src'
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user