mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-07 14:09:59 +00:00
Add support for new COMBO input spec and lazy/remote COMBO widgets (#2422)
This commit is contained in:
48
browser_tests/assets/remote_widget.json
Normal file
48
browser_tests/assets/remote_widget.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"last_node_id": 15,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 15,
|
||||
"type": "DevToolsRemoteWidgetNode",
|
||||
"pos": [
|
||||
495,
|
||||
735
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "DevToolsRemoteWidgetNode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"v1-5-pruned-emaonly-fp16.safetensors"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8008869919566275,
|
||||
"offset": [
|
||||
538.9801226576359,
|
||||
-55.24554581806672
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
258
browser_tests/remoteWidgets.spec.ts
Normal file
258
browser_tests/remoteWidgets.spec.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { ComfyPage, comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
|
||||
test.describe('Remote COMBO Widget', () => {
|
||||
const mockOptions = ['d', 'c', 'b', 'a']
|
||||
|
||||
const addRemoteWidgetNode = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeName: string,
|
||||
count: number = 1
|
||||
) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolder('DevTools').click()
|
||||
const nodeEntry = tab.getNode(nodeName).first()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await nodeEntry.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
const getWidgetOptions = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeName: string
|
||||
): Promise<string[] | undefined> => {
|
||||
return await comfyPage.page.evaluate((name) => {
|
||||
const node = window['app'].graph.nodes.find((node) => node.title === name)
|
||||
return node.widgets[0].options.values
|
||||
}, nodeName)
|
||||
}
|
||||
|
||||
const waitForWidgetUpdate = async (comfyPage: ComfyPage) => {
|
||||
// Force re-render to trigger first access of widget's options
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
await comfyPage.page.waitForTimeout(256)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.describe('Loading options', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.page.route(
|
||||
'**/api/models/checkpoints**',
|
||||
async (route, request) => {
|
||||
const params = new URL(request.url()).searchParams
|
||||
const sort = params.get('sort')
|
||||
await route.fulfill({
|
||||
body: JSON.stringify(sort ? [...mockOptions].sort() : mockOptions),
|
||||
status: 200
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.unroute('**/api/models/checkpoints**')
|
||||
})
|
||||
|
||||
test('lazy loads options when widget is added from node library', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeName = 'Remote Widget Node'
|
||||
await addRemoteWidgetNode(comfyPage, nodeName)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
const widgetOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
expect(widgetOptions).toEqual(mockOptions)
|
||||
})
|
||||
|
||||
test('lazy loads options when widget is added via workflow load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeName = 'Remote Widget Node'
|
||||
await comfyPage.loadWorkflow('remote_widget')
|
||||
await comfyPage.page.waitForTimeout(512)
|
||||
|
||||
const node = await comfyPage.page.evaluate((name) => {
|
||||
return window['app'].graph.nodes.find((node) => node.title === name)
|
||||
}, nodeName)
|
||||
expect(node).toBeDefined()
|
||||
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
const widgetOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
expect(widgetOptions).toEqual(mockOptions)
|
||||
})
|
||||
|
||||
test('applies query parameters from input spec', async ({ comfyPage }) => {
|
||||
const nodeName = 'Remote Widget Node With Sort Query Param'
|
||||
await addRemoteWidgetNode(comfyPage, nodeName)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
const widgetOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
expect(widgetOptions).not.toEqual(mockOptions)
|
||||
expect(widgetOptions).toEqual([...mockOptions].sort())
|
||||
})
|
||||
|
||||
test('handles empty list of options', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route(
|
||||
'**/api/models/checkpoints**',
|
||||
async (route) => {
|
||||
await route.fulfill({ body: JSON.stringify([]), status: 200 })
|
||||
}
|
||||
)
|
||||
|
||||
const nodeName = 'Remote Widget Node'
|
||||
await addRemoteWidgetNode(comfyPage, nodeName)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
const widgetOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
expect(widgetOptions).toEqual([])
|
||||
})
|
||||
|
||||
test('falls back to default value when non-200 response', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.route(
|
||||
'**/api/models/checkpoints**',
|
||||
async (route) => {
|
||||
await route.fulfill({ status: 500 })
|
||||
}
|
||||
)
|
||||
|
||||
const nodeName = 'Remote Widget Node'
|
||||
await addRemoteWidgetNode(comfyPage, nodeName)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
const widgetOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
|
||||
const defaultValue = 'Loading...'
|
||||
expect(widgetOptions).toEqual(defaultValue)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Lazy Loading Behavior', () => {
|
||||
test('does not fetch options before widget is added to graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
let requestWasMade = false
|
||||
|
||||
comfyPage.page.on('request', (request) => {
|
||||
if (request.url().includes('/api/models/checkpoints')) {
|
||||
requestWasMade = true
|
||||
}
|
||||
})
|
||||
|
||||
// Wait a reasonable time to ensure no request is made
|
||||
await comfyPage.page.waitForTimeout(512)
|
||||
expect(requestWasMade).toBe(false)
|
||||
})
|
||||
|
||||
test('fetches options immediately after widget is added to graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const requestPromise = comfyPage.page.waitForRequest((request) =>
|
||||
request.url().includes('/api/models/checkpoints')
|
||||
)
|
||||
await addRemoteWidgetNode(comfyPage, 'Remote Widget Node')
|
||||
const request = await requestPromise
|
||||
expect(request.url()).toContain('/api/models/checkpoints')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Refresh Behavior', () => {
|
||||
test('refreshes options when TTL expires', async ({ comfyPage }) => {
|
||||
// Fulfill each request with a unique timestamp
|
||||
await comfyPage.page.route(
|
||||
'**/api/models/checkpoints**',
|
||||
async (route, request) => {
|
||||
await route.fulfill({
|
||||
body: JSON.stringify([Date.now()]),
|
||||
status: 200
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const nodeName = 'Remote Widget Node With 300ms Refresh'
|
||||
await addRemoteWidgetNode(comfyPage, nodeName)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
const initialOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
|
||||
// Wait for the refresh (TTL) to expire
|
||||
await comfyPage.page.waitForTimeout(302)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
|
||||
const refreshedOptions = await getWidgetOptions(comfyPage, nodeName)
|
||||
expect(refreshedOptions).not.toEqual(initialOptions)
|
||||
})
|
||||
|
||||
test('does not refresh when TTL is not set', async ({ comfyPage }) => {
|
||||
let requestCount = 0
|
||||
await comfyPage.page.route(
|
||||
'**/api/models/checkpoints**',
|
||||
async (route) => {
|
||||
requestCount++
|
||||
await route.fulfill({ body: JSON.stringify(['test']), status: 200 })
|
||||
}
|
||||
)
|
||||
|
||||
const nodeName = 'Remote Widget Node'
|
||||
await addRemoteWidgetNode(comfyPage, nodeName)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
|
||||
// Force multiple re-renders
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
expect(requestCount).toBe(1) // Should only make initial request
|
||||
})
|
||||
|
||||
test('retries failed requests with backoff', async ({ comfyPage }) => {
|
||||
const timestamps: number[] = []
|
||||
await comfyPage.page.route(
|
||||
'**/api/models/checkpoints**',
|
||||
async (route) => {
|
||||
timestamps.push(Date.now())
|
||||
await route.fulfill({ status: 500 })
|
||||
}
|
||||
)
|
||||
|
||||
const nodeName = 'Remote Widget Node'
|
||||
await addRemoteWidgetNode(comfyPage, nodeName)
|
||||
|
||||
// Wait for a few retries
|
||||
await comfyPage.page.waitForTimeout(1024)
|
||||
|
||||
// Verify exponential backoff between retries
|
||||
const intervals = timestamps.slice(1).map((t, i) => t - timestamps[i])
|
||||
expect(intervals[1]).toBeGreaterThan(intervals[0])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cache Behavior', () => {
|
||||
test('reuses cached data between widgets with same params', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
let requestCount = 0
|
||||
await comfyPage.page.route(
|
||||
'**/api/models/checkpoints**',
|
||||
async (route) => {
|
||||
requestCount++
|
||||
await route.fulfill({
|
||||
body: JSON.stringify(mockOptions),
|
||||
status: 200
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Add two widgets with same config
|
||||
const nodeName = 'Remote Widget Node'
|
||||
await addRemoteWidgetNode(comfyPage, nodeName, 2)
|
||||
await waitForWidgetUpdate(comfyPage)
|
||||
|
||||
expect(requestCount).toBe(1) // Should reuse cached data
|
||||
})
|
||||
})
|
||||
})
|
||||
145
src/hooks/remoteWidgetHook.ts
Normal file
145
src/hooks/remoteWidgetHook.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import type { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
data: T[]
|
||||
timestamp: number
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
fetchPromise?: Promise<T[]>
|
||||
controller?: AbortController
|
||||
lastErrorTime: number
|
||||
retryCount: number
|
||||
}
|
||||
|
||||
const dataCache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
const createCacheKey = (inputData: InputSpec): string => {
|
||||
const { route, query_params = {}, refresh = 0 } = inputData[1]
|
||||
|
||||
const paramsKey = Object.entries(query_params)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&')
|
||||
|
||||
return [route, `r=${refresh}`, paramsKey].join(';')
|
||||
}
|
||||
|
||||
const getBackoff = (retryCount: number) => {
|
||||
return Math.min(1000 * Math.pow(2, retryCount), 512)
|
||||
}
|
||||
|
||||
async function fetchData<T>(
|
||||
inputData: InputSpec,
|
||||
controller: AbortController
|
||||
): Promise<T[]> {
|
||||
const { route, response_key, query_params } = inputData[1]
|
||||
const res = await axios.get(route, {
|
||||
params: query_params,
|
||||
signal: controller.signal,
|
||||
validateStatus: (status) => status === 200
|
||||
})
|
||||
return response_key ? res.data[response_key] : res.data
|
||||
}
|
||||
|
||||
export function useRemoteWidget<T>(inputData: InputSpec) {
|
||||
const { refresh = 0 } = inputData[1]
|
||||
const isPermanent = refresh <= 0
|
||||
const cacheKey = createCacheKey(inputData)
|
||||
const defaultValue = useWidgetStore().getDefaultValue(inputData)
|
||||
|
||||
const setSuccess = (entry: CacheEntry<T>, data: T[]) => {
|
||||
entry.retryCount = 0
|
||||
entry.lastErrorTime = 0
|
||||
entry.error = null
|
||||
entry.timestamp = Date.now()
|
||||
entry.data = data ?? defaultValue
|
||||
}
|
||||
|
||||
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
|
||||
entry.retryCount = (entry.retryCount || 0) + 1
|
||||
entry.lastErrorTime = Date.now()
|
||||
entry.error = error instanceof Error ? error : new Error(String(error))
|
||||
entry.data ??= defaultValue
|
||||
}
|
||||
|
||||
const isInitialized = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
return entry?.data && entry.timestamp > 0
|
||||
}
|
||||
|
||||
const isStale = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
return entry?.timestamp && Date.now() - entry.timestamp >= refresh
|
||||
}
|
||||
|
||||
const isFetching = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
return entry?.fetchPromise
|
||||
}
|
||||
|
||||
const isBackingOff = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
return (
|
||||
entry?.error &&
|
||||
entry.lastErrorTime &&
|
||||
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount)
|
||||
)
|
||||
}
|
||||
|
||||
const fetchOptions = async () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
|
||||
const isValid = isInitialized() && (isPermanent || !isStale())
|
||||
if (isValid || isBackingOff()) return entry!.data
|
||||
if (isFetching()) return entry!.fetchPromise
|
||||
|
||||
const currentEntry: CacheEntry<T> = entry || {
|
||||
data: defaultValue,
|
||||
timestamp: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
fetchPromise: undefined,
|
||||
controller: undefined,
|
||||
retryCount: 0,
|
||||
lastErrorTime: 0
|
||||
}
|
||||
dataCache.set(cacheKey, currentEntry)
|
||||
|
||||
try {
|
||||
currentEntry.loading = true
|
||||
currentEntry.error = null
|
||||
currentEntry.controller = new AbortController()
|
||||
|
||||
currentEntry.fetchPromise = fetchData<T>(
|
||||
inputData,
|
||||
currentEntry.controller
|
||||
)
|
||||
const data = await currentEntry.fetchPromise
|
||||
|
||||
setSuccess(currentEntry, data)
|
||||
return currentEntry.data
|
||||
} catch (err) {
|
||||
setError(currentEntry, err)
|
||||
return currentEntry.data
|
||||
} finally {
|
||||
currentEntry.loading = false
|
||||
currentEntry.fetchPromise = undefined
|
||||
currentEntry.controller = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getCacheKey: () => cacheKey,
|
||||
getCacheEntry: () => dataCache.get(cacheKey),
|
||||
forceUpdate: () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
if (entry?.fetchPromise) entry.controller?.abort() // Abort in-flight request
|
||||
dataCache.delete(cacheKey)
|
||||
},
|
||||
fetchOptions,
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import TiptapTableRow from '@tiptap/extension-table-row'
|
||||
import TiptapStarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
|
||||
|
||||
import { useRemoteWidget } from '@/hooks/remoteWidgetHook'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { InputSpec } from '@/types/apiTypes'
|
||||
|
||||
import { api } from './api'
|
||||
@@ -558,16 +560,47 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
||||
return res
|
||||
},
|
||||
COMBO(node, inputName, inputData: InputSpec) {
|
||||
const type = inputData[0]
|
||||
let defaultValue = type[0]
|
||||
if (inputData[1] && inputData[1].default) {
|
||||
defaultValue = inputData[1].default
|
||||
}
|
||||
const widgetStore = useWidgetStore()
|
||||
|
||||
const { type, options } = inputData[1]
|
||||
const defaultValue = widgetStore.getDefaultValue(inputData)
|
||||
|
||||
const res = {
|
||||
widget: node.addWidget('combo', inputName, defaultValue, () => {}, {
|
||||
values: type
|
||||
values: options ?? inputData[0]
|
||||
})
|
||||
}
|
||||
|
||||
if (type === 'remote') {
|
||||
const remoteWidget = useRemoteWidget(inputData)
|
||||
|
||||
const origOptions = res.widget.options
|
||||
res.widget.options = new Proxy(
|
||||
origOptions as Record<string | symbol, any>,
|
||||
{
|
||||
get(target, prop: string | symbol) {
|
||||
if (prop !== 'values') return target[prop]
|
||||
|
||||
remoteWidget.fetchOptions().then((options) => {
|
||||
if (!options || !options.length) return
|
||||
|
||||
const isUninitialized =
|
||||
res.widget.value === remoteWidget.defaultValue &&
|
||||
!res.widget.options.values?.includes(remoteWidget.defaultValue)
|
||||
if (isUninitialized) {
|
||||
res.widget.value = options[0]
|
||||
res.widget.callback?.(options[0])
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
})
|
||||
|
||||
const current = remoteWidget.getCacheEntry()
|
||||
return current?.data || widgetStore.getDefaultValue(inputData)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (inputData[1]?.control_after_generate) {
|
||||
// TODO make combo handle a widget node type?
|
||||
res.widget.linkedWidgets = addValueControlWidgets(
|
||||
|
||||
@@ -2,6 +2,11 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { ComfyWidgetConstructor, ComfyWidgets } from '@/scripts/widgets'
|
||||
import {
|
||||
ComboInputSpecV2,
|
||||
InputSpec,
|
||||
isComboInputSpecV1
|
||||
} from '@/types/apiTypes'
|
||||
|
||||
import type { BaseInputSpec } from './nodeDefStore'
|
||||
|
||||
@@ -38,10 +43,37 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultValue(inputData: InputSpec) {
|
||||
if (Array.isArray(inputData[0]))
|
||||
return getDefaultValue(transformComboInput(inputData))
|
||||
|
||||
const widgetType = getWidgetType(inputData[0], inputData[1].name)
|
||||
|
||||
const [_, props] = inputData
|
||||
if (props.default) return props.default
|
||||
|
||||
if (widgetType === 'COMBO' && props.options?.length) return props.options[0]
|
||||
if (props.type === 'remote') return 'Loading...'
|
||||
return undefined
|
||||
}
|
||||
|
||||
const transformComboInput = (inputData: InputSpec): ComboInputSpecV2 => {
|
||||
return isComboInputSpecV1(inputData)
|
||||
? [
|
||||
'COMBO',
|
||||
{
|
||||
options: inputData[0],
|
||||
...Object(inputData[1])
|
||||
}
|
||||
]
|
||||
: inputData
|
||||
}
|
||||
|
||||
return {
|
||||
widgets,
|
||||
getWidgetType,
|
||||
inputIsWidget,
|
||||
registerCustomWidgets
|
||||
registerCustomWidgets,
|
||||
getDefaultValue
|
||||
}
|
||||
})
|
||||
|
||||
@@ -329,18 +329,32 @@ const zStringInputSpec = inputSpec([
|
||||
})
|
||||
])
|
||||
|
||||
const zComboInputProps = zBaseInputSpecValue.extend({
|
||||
control_after_generate: z.boolean().optional(),
|
||||
image_upload: z.boolean().optional(),
|
||||
type: z.enum(['remote']).optional(),
|
||||
route: z.string().url().or(z.string().startsWith('/')).optional(),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
response_key: z.string().optional(),
|
||||
query_params: z.record(z.string(), z.string()).optional()
|
||||
})
|
||||
|
||||
// Dropdown Selection.
|
||||
const zComboInputSpec = inputSpec(
|
||||
[
|
||||
z.array(z.any()),
|
||||
zBaseInputSpecValue.extend({
|
||||
control_after_generate: z.boolean().optional(),
|
||||
image_upload: z.boolean().optional()
|
||||
})
|
||||
],
|
||||
[z.array(z.any()), zComboInputProps],
|
||||
/* allowUpcast=*/ false
|
||||
)
|
||||
|
||||
const zComboInputSpecV2 = inputSpec(
|
||||
[z.literal('COMBO'), zComboInputProps],
|
||||
/* allowUpcast=*/ false
|
||||
)
|
||||
export function isComboInputSpecV1(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec {
|
||||
return Array.isArray(inputSpec[0])
|
||||
}
|
||||
|
||||
const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO'])
|
||||
|
||||
const zCustomInputSpec = inputSpec([
|
||||
@@ -354,6 +368,7 @@ const zInputSpec = z.union([
|
||||
zBooleanInputSpec,
|
||||
zStringInputSpec,
|
||||
zComboInputSpec,
|
||||
zComboInputSpecV2,
|
||||
zCustomInputSpec
|
||||
])
|
||||
|
||||
@@ -388,6 +403,8 @@ const zComfyNodeDef = z.object({
|
||||
})
|
||||
|
||||
// `/object_info`
|
||||
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
|
||||
export type ComboInputSpecV2 = z.infer<typeof zComboInputSpecV2>
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
|
||||
496
tests-ui/tests/remoteWidgetHook.test.ts
Normal file
496
tests-ui/tests/remoteWidgetHook.test.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { useRemoteWidget } from '@/hooks/remoteWidgetHook'
|
||||
import type { ComboInputSpecV2 } from '@/types/apiTypes'
|
||||
|
||||
jest.mock('axios', () => ({
|
||||
get: jest.fn()
|
||||
}))
|
||||
|
||||
jest.mock('@/i18n', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
t: jest.fn((key) => key)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
jest.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
settings: {}
|
||||
})
|
||||
}))
|
||||
|
||||
jest.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => ({
|
||||
widgets: {},
|
||||
getDefaultValue: jest.fn().mockReturnValue('Loading...')
|
||||
})
|
||||
}))
|
||||
|
||||
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
|
||||
|
||||
function createMockInputData(overrides = {}): ComboInputSpecV2 {
|
||||
return [
|
||||
'COMBO',
|
||||
{
|
||||
name: 'test_widget',
|
||||
type: 'remote',
|
||||
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
|
||||
refresh: 0,
|
||||
...overrides
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function mockAxiosResponse(data: unknown, status = 200) {
|
||||
jest.mocked(axios.get).mockResolvedValueOnce({ data, status })
|
||||
}
|
||||
|
||||
function mockAxiosError(error: Error | string) {
|
||||
const err = error instanceof Error ? error : new Error(error)
|
||||
jest.mocked(axios.get).mockRejectedValueOnce(err)
|
||||
}
|
||||
|
||||
function createHookWithData(data: unknown, inputOverrides = {}) {
|
||||
mockAxiosResponse(data)
|
||||
const hook = useRemoteWidget(createMockInputData(inputOverrides))
|
||||
return hook
|
||||
}
|
||||
|
||||
async function setupHookWithResponse(data: unknown, inputOverrides = {}) {
|
||||
const hook = createHookWithData(data, inputOverrides)
|
||||
const result = await hook.fetchOptions()
|
||||
return { hook, result }
|
||||
}
|
||||
|
||||
describe('useRemoteWidget', () => {
|
||||
let mockInputData: ComboInputSpecV2
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Reset mocks
|
||||
jest.mocked(axios.get).mockReset()
|
||||
// Reset cache between tests
|
||||
jest.spyOn(Map.prototype, 'get').mockClear()
|
||||
jest.spyOn(Map.prototype, 'set').mockClear()
|
||||
jest.spyOn(Map.prototype, 'delete').mockClear()
|
||||
|
||||
mockInputData = createMockInputData()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create hook with default values', () => {
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
expect(hook.getCacheEntry()).toBeUndefined()
|
||||
expect(hook.defaultValue).toBe('Loading...')
|
||||
})
|
||||
|
||||
it('should generate consistent cache keys', () => {
|
||||
const hook1 = useRemoteWidget(mockInputData)
|
||||
const hook2 = useRemoteWidget(mockInputData)
|
||||
expect(hook1.getCacheKey()).toBe(hook2.getCacheKey())
|
||||
})
|
||||
|
||||
it('should handle query params in cache key', () => {
|
||||
const hook1 = useRemoteWidget(
|
||||
createMockInputData({ query_params: { a: 1 } })
|
||||
)
|
||||
const hook2 = useRemoteWidget(
|
||||
createMockInputData({ query_params: { a: 2 } })
|
||||
)
|
||||
expect(hook1.getCacheKey()).not.toBe(hook2.getCacheKey())
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchOptions', () => {
|
||||
it('should fetch data successfully', async () => {
|
||||
const mockData = ['optionA', 'optionB']
|
||||
const { hook, result } = await setupHookWithResponse(mockData)
|
||||
expect(result).toEqual(mockData)
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledWith(
|
||||
hook.getCacheKey().split(';')[0], // Get the route part from cache key
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should use response_key if provided', async () => {
|
||||
const mockResponse = { items: ['optionB', 'optionA', 'optionC'] }
|
||||
const { result } = await setupHookWithResponse(mockResponse, {
|
||||
response_key: 'items'
|
||||
})
|
||||
expect(result).toEqual(mockResponse.items)
|
||||
})
|
||||
|
||||
it('should cache successful responses', async () => {
|
||||
const mockData = ['optionA', 'optionB', 'optionC', 'optionD']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
const entry = hook.getCacheEntry()
|
||||
|
||||
expect(entry?.data).toEqual(mockData)
|
||||
expect(entry?.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
const error = new Error('Network error')
|
||||
mockAxiosError(error)
|
||||
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const data = await hook.fetchOptions()
|
||||
expect(data).toBe('Loading...')
|
||||
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error).toBeTruthy()
|
||||
expect(entry?.lastErrorTime).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle empty array responses', async () => {
|
||||
const { result } = await setupHookWithResponse([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle malformed response data', async () => {
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const { defaultValue } = hook
|
||||
|
||||
mockAxiosResponse(null)
|
||||
const data1 = await hook.fetchOptions()
|
||||
|
||||
mockAxiosResponse(undefined)
|
||||
const data2 = await hook.fetchOptions()
|
||||
|
||||
expect(data1).toBe(defaultValue)
|
||||
expect(data2).toBe(defaultValue)
|
||||
})
|
||||
|
||||
it('should handle non-200 status codes', async () => {
|
||||
mockAxiosError('Request failed with status code 404')
|
||||
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const data = await hook.fetchOptions()
|
||||
|
||||
expect(data).toBe('Loading...')
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error?.message).toBe('Request failed with status code 404')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh behavior', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('permanent widgets (no refresh)', () => {
|
||||
it('permanent widgets should not attempt fetch after initialization', async () => {
|
||||
const mockData = ['data that is permanent after initialization']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
|
||||
await hook.fetchOptions()
|
||||
await hook.fetchOptions()
|
||||
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('permanent widgets should re-fetch if forceUpdate is called', async () => {
|
||||
const mockData = ['data that is permanent after initialization']
|
||||
const { hook } = await setupHookWithResponse(mockData)
|
||||
|
||||
await hook.fetchOptions()
|
||||
const refreshedData = ['data that user forced to be fetched']
|
||||
mockAxiosResponse(refreshedData)
|
||||
|
||||
await hook.forceUpdate()
|
||||
const data = await hook.fetchOptions()
|
||||
expect(data).toEqual(refreshedData)
|
||||
})
|
||||
|
||||
it('permanent widgets should still retry if request fails', async () => {
|
||||
mockAxiosError('Network error')
|
||||
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
await hook.fetchOptions()
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
|
||||
jest.setSystemTime(Date.now() + FIRST_BACKOFF)
|
||||
const secondData = await hook.fetchOptions()
|
||||
expect(secondData).toBe('Loading...')
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should treat empty refresh field as permanent', async () => {
|
||||
const { hook } = await setupHookWithResponse(['data that is permanent'])
|
||||
|
||||
await hook.fetchOptions()
|
||||
await hook.fetchOptions()
|
||||
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should refresh when data is stale', async () => {
|
||||
const refresh = 256
|
||||
const mockData1 = ['option1']
|
||||
const mockData2 = ['option2']
|
||||
|
||||
const { hook } = await setupHookWithResponse(mockData1, { refresh })
|
||||
mockAxiosResponse(mockData2)
|
||||
|
||||
jest.setSystemTime(Date.now() + refresh)
|
||||
const newData = await hook.fetchOptions()
|
||||
|
||||
expect(newData).toEqual(mockData2)
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should not refresh when data is not stale', async () => {
|
||||
const { hook } = await setupHookWithResponse(['option1'], {
|
||||
refresh: 512
|
||||
})
|
||||
|
||||
jest.setSystemTime(Date.now() + 128)
|
||||
await hook.fetchOptions()
|
||||
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use backoff instead of refresh after error', async () => {
|
||||
const refresh = 4096
|
||||
const { hook } = await setupHookWithResponse(['first success'], {
|
||||
refresh
|
||||
})
|
||||
|
||||
mockAxiosError('Network error')
|
||||
jest.setSystemTime(Date.now() + refresh)
|
||||
await hook.fetchOptions()
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
|
||||
mockAxiosResponse(['second success'])
|
||||
jest.setSystemTime(Date.now() + FIRST_BACKOFF)
|
||||
const thirdData = await hook.fetchOptions()
|
||||
expect(thirdData).toEqual(['second success'])
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should use last valid value after error', async () => {
|
||||
const refresh = 4096
|
||||
const { hook } = await setupHookWithResponse(['a valid value'], {
|
||||
refresh
|
||||
})
|
||||
|
||||
mockAxiosError('Network error')
|
||||
jest.setSystemTime(Date.now() + refresh)
|
||||
const secondData = await hook.fetchOptions()
|
||||
|
||||
expect(secondData).toEqual(['a valid value'])
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling and backoff', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should implement exponential backoff on errors', async () => {
|
||||
mockAxiosError('Network error')
|
||||
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
await hook.fetchOptions()
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
await hook.fetchOptions()
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
|
||||
jest.setSystemTime(Date.now() + 500)
|
||||
await hook.fetchOptions()
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
|
||||
|
||||
jest.setSystemTime(Date.now() + 3000)
|
||||
await hook.fetchOptions()
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
expect(entry1?.data).toBeDefined()
|
||||
})
|
||||
|
||||
it('should reset error state on successful fetch', async () => {
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const firstData = await hook.fetchOptions()
|
||||
expect(firstData).toBe('Loading...')
|
||||
|
||||
jest.setSystemTime(Date.now() + 3000)
|
||||
mockAxiosResponse(['option1'])
|
||||
const secondData = await hook.fetchOptions()
|
||||
expect(secondData).toEqual(['option1'])
|
||||
|
||||
const entry = hook.getCacheEntry()
|
||||
expect(entry?.error).toBeNull()
|
||||
expect(entry?.retryCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should save successful data after backoff', async () => {
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
await hook.fetchOptions()
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
jest.setSystemTime(Date.now() + 3000)
|
||||
mockAxiosResponse(['success after backoff'])
|
||||
const secondData = await hook.fetchOptions()
|
||||
expect(secondData).toEqual(['success after backoff'])
|
||||
|
||||
const entry2 = hook.getCacheEntry()
|
||||
expect(entry2?.error).toBeNull()
|
||||
expect(entry2?.retryCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should save successful data after multiple backoffs', async () => {
|
||||
mockAxiosError('Network error')
|
||||
mockAxiosError('Network error')
|
||||
mockAxiosError('Network error')
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
await hook.fetchOptions()
|
||||
const entry1 = hook.getCacheEntry()
|
||||
expect(entry1?.error).toBeTruthy()
|
||||
|
||||
jest.setSystemTime(Date.now() + 3000)
|
||||
const secondData = await hook.fetchOptions()
|
||||
expect(secondData).toBe('Loading...')
|
||||
expect(entry1?.error).toBeDefined()
|
||||
|
||||
jest.setSystemTime(Date.now() + 9000)
|
||||
const thirdData = await hook.fetchOptions()
|
||||
expect(thirdData).toBe('Loading...')
|
||||
expect(entry1?.error).toBeDefined()
|
||||
|
||||
jest.setSystemTime(Date.now() + 120_000)
|
||||
mockAxiosResponse(['success after multiple backoffs'])
|
||||
const fourthData = await hook.fetchOptions()
|
||||
expect(fourthData).toEqual(['success after multiple backoffs'])
|
||||
|
||||
const entry2 = hook.getCacheEntry()
|
||||
expect(entry2?.error).toBeNull()
|
||||
expect(entry2?.retryCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache management', () => {
|
||||
it('should clear cache entries', async () => {
|
||||
const { hook } = await setupHookWithResponse(['to be cleared'])
|
||||
expect(hook.getCacheEntry()).toBeDefined()
|
||||
|
||||
hook.forceUpdate()
|
||||
expect(hook.getCacheEntry()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should prevent duplicate in-flight requests', async () => {
|
||||
const promise = Promise.resolve({ data: ['non-duplicate'] })
|
||||
jest.mocked(axios.get).mockImplementationOnce(() => promise)
|
||||
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const [result1, result2] = await Promise.all([
|
||||
hook.fetchOptions(),
|
||||
hook.fetchOptions()
|
||||
])
|
||||
|
||||
expect(result1).toBe(result2)
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent access and multiple instances', () => {
|
||||
it('should handle concurrent hook instances with same route', async () => {
|
||||
mockAxiosResponse(['shared data'])
|
||||
const hook1 = useRemoteWidget(mockInputData)
|
||||
const hook2 = useRemoteWidget(mockInputData)
|
||||
|
||||
const [data1, data2] = await Promise.all([
|
||||
hook1.fetchOptions(),
|
||||
hook2.fetchOptions()
|
||||
])
|
||||
|
||||
expect(data1).toEqual(['shared data'])
|
||||
expect(data2).toEqual(['shared data'])
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
expect(hook1.getCacheEntry()).toBe(hook2.getCacheEntry())
|
||||
})
|
||||
|
||||
it('should use shared cache across multiple hooks', async () => {
|
||||
mockAxiosResponse(['shared data'])
|
||||
const hook1 = useRemoteWidget(mockInputData)
|
||||
const hook2 = useRemoteWidget(mockInputData)
|
||||
const hook3 = useRemoteWidget(mockInputData)
|
||||
const hook4 = useRemoteWidget(mockInputData)
|
||||
|
||||
const data1 = await hook1.fetchOptions()
|
||||
const data2 = await hook2.fetchOptions()
|
||||
const data3 = await hook3.fetchOptions()
|
||||
const data4 = await hook4.fetchOptions()
|
||||
|
||||
expect(data1).toEqual(['shared data'])
|
||||
expect(data2).toBe(data1)
|
||||
expect(data3).toBe(data1)
|
||||
expect(data4).toBe(data1)
|
||||
expect(jest.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
expect(hook1.getCacheEntry()).toBe(hook2.getCacheEntry())
|
||||
expect(hook2.getCacheEntry()).toBe(hook3.getCacheEntry())
|
||||
expect(hook3.getCacheEntry()).toBe(hook4.getCacheEntry())
|
||||
})
|
||||
|
||||
it('should handle rapid cache clearing during fetch', async () => {
|
||||
let resolvePromise: (value: any) => void
|
||||
const delayedPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
jest.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
|
||||
|
||||
const hook = useRemoteWidget(mockInputData)
|
||||
const fetchPromise = hook.fetchOptions()
|
||||
hook.forceUpdate()
|
||||
|
||||
resolvePromise!({ data: ['delayed data'] })
|
||||
const data = await fetchPromise
|
||||
|
||||
expect(data).toEqual(['delayed data'])
|
||||
expect(hook.getCacheEntry()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle widget destroyed during fetch', async () => {
|
||||
let resolvePromise: (value: any) => void
|
||||
const delayedPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
jest.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
|
||||
|
||||
let hook = useRemoteWidget(mockInputData)
|
||||
const fetchPromise = hook.fetchOptions()
|
||||
|
||||
hook = null as any
|
||||
|
||||
resolvePromise!({ data: ['delayed data'] })
|
||||
await fetchPromise
|
||||
|
||||
expect(hook).toBeNull()
|
||||
hook = useRemoteWidget(mockInputData)
|
||||
|
||||
const data2 = await hook.fetchOptions()
|
||||
expect(data2).toEqual(['delayed data'])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user