Merge branch 'main' into clean-tsconfig

This commit is contained in:
sno
2025-10-26 14:54:10 +09:00
committed by GitHub
50 changed files with 1431 additions and 58 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -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/*',

View File

@@ -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
View 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())
})

View File

@@ -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()) {

View File

@@ -8,6 +8,9 @@
:max-selected-labels="3"
:display="display"
class="w-full"
:pt="{
dropdownIcon: 'text-button-icon'
}"
/>
</div>
</template>

View File

@@ -128,6 +128,17 @@ export function useTemplateFiltering(
})
})
const getVramMetric = (template: TemplateInfo) => {
if (
typeof template.vram === 'number' &&
Number.isFinite(template.vram) &&
template.vram > 0
) {
return template.vram
}
return Number.POSITIVE_INFINITY
}
const sortedTemplates = computed(() => {
const templates = [...filteredByLicenses.value]
@@ -145,9 +156,21 @@ export function useTemplateFiltering(
return dateB.getTime() - dateA.getTime()
})
case 'vram-low-to-high':
// TODO: Implement VRAM sorting when VRAM data is available
// For now, keep original order
return templates
return templates.sort((a, b) => {
const vramA = getVramMetric(a)
const vramB = getVramMetric(b)
if (vramA === vramB) {
const nameA = a.title || a.name || ''
const nameB = b.title || b.name || ''
return nameA.localeCompare(nameB)
}
if (vramA === Number.POSITIVE_INFINITY) return 1
if (vramB === Number.POSITIVE_INFINITY) return -1
return vramA - vramB
})
case 'model-size-low-to-high':
return templates.sort((a: any, b: any) => {
const sizeA =

View File

@@ -1,5 +1,7 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import { d, t } from '@/i18n'
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
@@ -15,19 +17,6 @@ function filterByCategory(category: string) {
}
}
function filterByQuery(query: string) {
return (asset: AssetItem) => {
if (!query) return true
const lowerQuery = query.toLowerCase()
const description = getAssetDescription(asset)
return (
asset.name.toLowerCase().includes(lowerQuery) ||
(description && description.toLowerCase().includes(lowerQuery)) ||
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
)
}
}
function filterByFileFormats(formats: string[]) {
return (asset: AssetItem) => {
if (formats.length === 0) return true
@@ -160,9 +149,31 @@ export function useAssetBrowser(
return assets.value.filter(filterByCategory(selectedCategory.value))
})
const fuseOptions: UseFuseOptions<AssetItem> = {
fuseOptions: {
keys: [
{ name: 'name', weight: 0.4 },
{ name: 'tags', weight: 0.3 }
],
threshold: 0.4, // Higher threshold for typo tolerance (0.0 = exact, 1.0 = match all)
ignoreLocation: true, // Search anywhere in the string, not just at the beginning
includeScore: true
},
matchAllWhenSearchEmpty: true
}
const { results: fuseResults } = useFuse(
searchQuery,
categoryFilteredAssets,
fuseOptions
)
const searchFiltered = computed(() =>
fuseResults.value.map((result) => result.item)
)
const filteredAssets = computed(() => {
const filtered = categoryFilteredAssets.value
.filter(filterByQuery(searchQuery.value))
const filtered = searchFiltered.value
.filter(filterByFileFormats(filters.value.fileFormats))
.filter(filterByBaseModels(filters.value.baseModels))

View File

@@ -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()

View File

@@ -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
})
)
}

View File

@@ -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'
}))
}
}

View File

@@ -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 }
}

View 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'

View 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 }

View 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 }

View 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'

View File

@@ -18,6 +18,10 @@ export interface TemplateInfo {
date?: string
useCase?: string
license?: string
/**
* Estimated VRAM requirement in bytes.
*/
vram?: number
size?: number
}

View File

@@ -25,7 +25,8 @@
class="max-w-[20em] min-w-[8em] text-xs"
size="small"
:pt="{
option: 'text-xs'
option: 'text-xs',
dropdownIcon: 'text-button-icon'
}"
/>
<Button
@@ -95,7 +96,8 @@
class="max-w-[20em] min-w-[8em] text-xs"
size="small"
:pt="{
option: 'text-xs'
option: 'text-xs',
dropdownIcon: 'text-button-icon'
}"
/>
<Button

View File

@@ -103,10 +103,10 @@ const inputNumberPt = useNumberWidgetButtonPt({
@update:model-value="onChange"
>
<template #incrementicon>
<span class="pi pi-plus text-sm" />
<span class="pi pi-plus text-sm text-button-icon" />
</template>
<template #decrementicon>
<span class="pi pi-minus text-sm" />
<span class="pi pi-minus text-sm text-button-icon" />
</template>
</InputNumber>
</div>

View File

@@ -9,7 +9,8 @@
size="small"
display="chip"
:pt="{
option: 'text-xs'
option: 'text-xs',
dropdownIcon: 'text-button-icon'
}"
@update:model-value="onChange"
/>

View File

@@ -8,7 +8,8 @@
:aria-label="widget.name"
size="small"
:pt="{
option: 'text-xs'
option: 'text-xs',
dropdownIcon: 'text-button-icon'
}"
data-capture-wheel="true"
@update:model-value="onChange"

View File

@@ -6,6 +6,9 @@
class="w-full text-xs"
:aria-label="widget.name"
size="small"
:pt="{
dropdownIcon: 'text-button-icon'
}"
@update:model-value="onChange"
/>
</WidgetLayoutField>

View File

@@ -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'),

View File

@@ -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
View File

@@ -16,4 +16,8 @@ declare global {
interface Window {
__COMFYUI_FRONTEND_VERSION__: string
}
const __DEV_SERVER_COMFYUI_URL__: string
}
export {}

View 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' }
}
}
]

View File

@@ -136,8 +136,8 @@ describe('useAssetBrowser', () => {
})
})
describe('Search Functionality', () => {
it('searches across asset name', async () => {
describe('Fuzzy Search Functionality', () => {
it('searches across asset name with exact match', async () => {
const assets = [
createApiAsset({ name: 'realistic_vision.safetensors' }),
createApiAsset({ name: 'anime_style.ckpt' }),
@@ -149,45 +149,148 @@ describe('useAssetBrowser', () => {
searchQuery.value = 'realistic'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1)
expect(
filteredAssets.value.every((asset) =>
filteredAssets.value.some((asset) =>
asset.name.toLowerCase().includes('realistic')
)
).toBe(true)
})
it('searches in user metadata description', async () => {
it('searches across asset tags', async () => {
const assets = [
createApiAsset({
name: 'model1.safetensors',
user_metadata: { description: 'fantasy artwork model' }
tags: ['models', 'checkpoints']
}),
createApiAsset({
name: 'model2.safetensors',
user_metadata: { description: 'portrait photography' }
tags: ['models', 'loras']
})
]
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
searchQuery.value = 'fantasy'
searchQuery.value = 'checkpoints'
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].name).toBe('model1.safetensors')
expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1)
expect(filteredAssets.value[0].tags).toContain('checkpoints')
})
it('handles empty search results', async () => {
it('supports fuzzy matching with typos', async () => {
const assets = [
createApiAsset({ name: 'checkpoint_model.safetensors' }),
createApiAsset({ name: 'lora_model.safetensors' })
]
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
// Intentional typo - fuzzy search should still find it
searchQuery.value = 'chckpoint'
await nextTick()
expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1)
expect(filteredAssets.value[0].name).toContain('checkpoint')
})
it('handles empty search by returning all assets', async () => {
const assets = [
createApiAsset({ name: 'test1.safetensors' }),
createApiAsset({ name: 'test2.safetensors' })
]
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
searchQuery.value = ''
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
})
it('handles no search results', async () => {
const assets = [createApiAsset({ name: 'test.safetensors' })]
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
searchQuery.value = 'nonexistent'
searchQuery.value = 'completelydifferentstring123'
await nextTick()
expect(filteredAssets.value).toHaveLength(0)
})
it('performs case-insensitive search', async () => {
const assets = [
createApiAsset({ name: 'RealisticVision.safetensors' }),
createApiAsset({ name: 'anime_style.ckpt' })
]
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
searchQuery.value = 'REALISTIC'
await nextTick()
expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1)
expect(filteredAssets.value[0].name).toContain('Realistic')
})
it('combines fuzzy search with format filter', async () => {
const assets = [
createApiAsset({ name: 'my_checkpoint_model.safetensors' }),
createApiAsset({ name: 'my_checkpoint_model.ckpt' }),
createApiAsset({ name: 'different_lora.safetensors' })
]
const { searchQuery, updateFilters, filteredAssets } = useAssetBrowser(
ref(assets)
)
searchQuery.value = 'checkpoint'
updateFilters({
sortBy: 'name-asc',
fileFormats: ['safetensors'],
baseModels: []
})
await nextTick()
expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1)
expect(
filteredAssets.value.every((asset) =>
asset.name.endsWith('.safetensors')
)
).toBe(true)
expect(
filteredAssets.value.some((asset) => asset.name.includes('checkpoint'))
).toBe(true)
})
it('combines fuzzy search with base model filter', async () => {
const assets = [
createApiAsset({
name: 'realistic_sd15.safetensors',
user_metadata: { base_model: 'SD1.5' }
}),
createApiAsset({
name: 'realistic_sdxl.safetensors',
user_metadata: { base_model: 'SDXL' }
})
]
const { searchQuery, updateFilters, filteredAssets } = useAssetBrowser(
ref(assets)
)
searchQuery.value = 'realistic'
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: ['SDXL']
})
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].name).toBe('realistic_sdxl.safetensors')
})
})
describe('Combined Search and Filtering', () => {

View File

@@ -0,0 +1,231 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
describe('useTemplateFiltering', () => {
afterEach(() => {
vi.useRealTimers()
})
it('sorts templates by VRAM from low to high and pushes missing values last', () => {
const gb = (value: number) => value * 1024 ** 3
const templates = ref<TemplateInfo[]>([
{
name: 'missing-vram',
description: 'no vram value',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'highest-vram',
description: 'high usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(12)
},
{
name: 'mid-vram',
description: 'medium usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(7.5)
},
{
name: 'low-vram',
description: 'low usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(5)
},
{
name: 'zero-vram',
description: 'unknown usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: 0
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'vram-low-to-high'
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'low-vram',
'mid-vram',
'highest-vram',
'missing-vram',
'zero-vram'
])
})
it('filters by search text, models, tags, and license with debounce handling', async () => {
vi.useFakeTimers()
const templates = ref<TemplateInfo[]>([
{
name: 'api-template',
description: 'Enterprise API workflow for video',
mediaType: 'image',
mediaSubtype: 'png',
tags: ['API', 'Video'],
models: ['Flux'],
date: '2024-06-01',
vram: 15 * 1024 ** 3
},
{
name: 'portrait-flow',
description: 'Portrait template tuned for SDXL',
mediaType: 'image',
mediaSubtype: 'png',
tags: ['Portrait'],
models: ['SDXL'],
date: '2024-05-15',
vram: 10 * 1024 ** 3
},
{
name: 'landscape-lite',
description: 'Lightweight landscape generator',
mediaType: 'image',
mediaSubtype: 'png',
tags: ['Landscape'],
models: ['SDXL', 'Flux'],
date: '2024-04-20'
}
])
const {
searchQuery,
selectedModels,
selectedUseCases,
selectedLicenses,
filteredTemplates,
availableModels,
availableUseCases,
availableLicenses,
filteredCount,
totalCount,
removeUseCaseFilter,
resetFilters
} = useTemplateFiltering(templates)
expect(totalCount.value).toBe(3)
expect(availableModels.value).toEqual(['Flux', 'SDXL'])
expect(availableUseCases.value).toEqual([
'API',
'Landscape',
'Portrait',
'Video'
])
expect(availableLicenses.value).toEqual([
'Open Source',
'Closed Source (API Nodes)'
])
searchQuery.value = 'enterprise'
await nextTick()
await vi.runOnlyPendingTimersAsync()
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template'
])
selectedLicenses.value = ['Closed Source (API Nodes)']
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template'
])
selectedModels.value = ['Flux']
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template'
])
selectedUseCases.value = ['Video']
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template'
])
expect(filteredCount.value).toBe(1)
removeUseCaseFilter('Video')
await nextTick()
expect(selectedUseCases.value).toHaveLength(0)
resetFilters()
await nextTick()
await vi.runOnlyPendingTimersAsync()
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template',
'portrait-flow',
'landscape-lite'
])
})
it('supports alphabetical, newest, and size-based sorting options', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'zeta-extended',
description: 'older template',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-01-01',
size: 300
},
{
name: 'alpha-starter',
description: 'new template',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-07-01',
size: 100
},
{
name: 'beta-pro',
description: 'mid template',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-05-01',
size: 200
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
// default is 'newest'
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'alpha-starter',
'beta-pro',
'zeta-extended'
])
sortBy.value = 'alphabetical'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'alpha-starter',
'beta-pro',
'zeta-extended'
])
sortBy.value = 'model-size-low-to-high'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'alpha-starter',
'beta-pro',
'zeta-extended'
])
sortBy.value = 'default'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'zeta-extended',
'alpha-starter',
'beta-pro'
])
})
})

View File

@@ -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()
})
})
})

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 '@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([])
})
})

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 '@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)
})
})
})

View File

@@ -33,6 +33,9 @@
],
"@/utils/networkUtil": [
"packages/shared-frontend-utils/src/networkUtil.ts"
],
"@tests-ui/*": [
"tests-ui/*"
]
},
"typeRoots": [

View File

@@ -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: {

View File

@@ -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'
}
},