diff --git a/eslint.config.ts b/eslint.config.ts index b66674865..e4b151080 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -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/*', diff --git a/knip.config.ts b/knip.config.ts index 928483060..6f5487eba 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -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 diff --git a/public/auth-dev-sw.js b/public/auth-dev-sw.js new file mode 100644 index 000000000..c721ba2ac --- /dev/null +++ b/public/auth-dev-sw.js @@ -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|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} + */ +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} + */ +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()) +}) diff --git a/src/App.vue b/src/App.vue index c0c9fdd20..a2bd28fdd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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()) { diff --git a/src/platform/auth/serviceWorker/register.ts b/src/platform/auth/serviceWorker/register.ts index 0f44f3b9b..c752721f7 100644 --- a/src/platform/auth/serviceWorker/register.ts +++ b/src/platform/auth/serviceWorker/register.ts @@ -13,7 +13,34 @@ async function registerAuthServiceWorker(): Promise { } 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() diff --git a/src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.ts b/src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.ts new file mode 100644 index 000000000..5b0e9f266 --- /dev/null +++ b/src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.ts @@ -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 + }) + ) +} diff --git a/src/platform/remote/comfyui/history/fetchers/fetchHistoryV1.ts b/src/platform/remote/comfyui/history/fetchers/fetchHistoryV1.ts new file mode 100644 index 000000000..1034753a9 --- /dev/null +++ b/src/platform/remote/comfyui/history/fetchers/fetchHistoryV1.ts @@ -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, + maxItems: number = 200 +): Promise { + const res = await fetchApi(`/history?max_items=${maxItems}`) + const json: Record< + string, + Omit + > = await res.json() + + return { + History: Object.values(json).map((item) => ({ + ...item, + taskType: 'History' + })) + } +} diff --git a/src/platform/remote/comfyui/history/fetchers/fetchHistoryV2.ts b/src/platform/remote/comfyui/history/fetchers/fetchHistoryV2.ts new file mode 100644 index 000000000..129c0ab8a --- /dev/null +++ b/src/platform/remote/comfyui/history/fetchers/fetchHistoryV2.ts @@ -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, + maxItems: number = 200 +): Promise { + const res = await fetchApi(`/history_v2?max_items=${maxItems}`) + const rawData: HistoryResponseV2 = await res.json() + const adaptedHistory = mapHistoryV2toHistory(rawData) + return { History: adaptedHistory } +} diff --git a/src/platform/remote/comfyui/history/index.ts b/src/platform/remote/comfyui/history/index.ts new file mode 100644 index 000000000..fc96225e4 --- /dev/null +++ b/src/platform/remote/comfyui/history/index.ts @@ -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' diff --git a/src/platform/remote/comfyui/history/types/historyV1Types.ts b/src/platform/remote/comfyui/history/types/historyV1Types.ts new file mode 100644 index 000000000..f7bff7a84 --- /dev/null +++ b/src/platform/remote/comfyui/history/types/historyV1Types.ts @@ -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 } diff --git a/src/platform/remote/comfyui/history/types/historyV2Types.ts b/src/platform/remote/comfyui/history/types/historyV2Types.ts new file mode 100644 index 000000000..16c4fa000 --- /dev/null +++ b/src/platform/remote/comfyui/history/types/historyV2Types.ts @@ -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 +export type RawHistoryItemV2 = z.infer +export type HistoryResponseV2 = z.infer +export type TaskOutput = z.infer + +export { zRawHistoryItemV2 } diff --git a/src/platform/remote/comfyui/history/types/index.ts b/src/platform/remote/comfyui/history/types/index.ts new file mode 100644 index 000000000..d49f66ffe --- /dev/null +++ b/src/platform/remote/comfyui/history/types/index.ts @@ -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' diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 4689a087c..df1b102d1 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -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 @@ -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'), diff --git a/src/scripts/api.ts b/src/scripts/api.ts index 27e459f46..e43bd39aa 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -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 = 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: [] } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index c6fdfff71..3a8ce7ade 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -16,4 +16,8 @@ declare global { interface Window { __COMFYUI_FRONTEND_VERSION__: string } + + const __DEV_SERVER_COMFYUI_URL__: string } + +export {} diff --git a/tests-ui/fixtures/historyFixtures.ts b/tests-ui/fixtures/historyFixtures.ts new file mode 100644 index 000000000..5ae3ff998 --- /dev/null +++ b/tests-ui/fixtures/historyFixtures.ts @@ -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 +> = { + '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' } + } + } +] diff --git a/tests-ui/tests/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts b/tests-ui/tests/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts new file mode 100644 index 000000000..f77a5e158 --- /dev/null +++ b/tests-ui/tests/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts @@ -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() + }) + }) +}) diff --git a/tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV1.test.ts b/tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV1.test.ts new file mode 100644 index 000000000..e0869778e --- /dev/null +++ b/tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV1.test.ts @@ -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([]) + }) +}) diff --git a/tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV2.test.ts b/tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV2.test.ts new file mode 100644 index 000000000..ff0e28750 --- /dev/null +++ b/tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV2.test.ts @@ -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) + }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 1a2658b90..8319194da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,9 @@ ], "@/utils/networkUtil": [ "packages/shared-frontend-utils/src/networkUtil.ts" + ], + "@tests-ui/*": [ + "tests-ui/*" ] }, "typeRoots": [ diff --git a/vite.config.mts b/vite.config.mts index 908e280b5..88631efeb 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -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: { diff --git a/vitest.config.ts b/vitest.config.ts index 342de4e04..1dab6ffd1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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' } },