Add support for new COMBO input spec and lazy/remote COMBO widgets (#2422)

This commit is contained in:
bymyself
2025-02-07 13:35:42 -07:00
committed by GitHub
parent 340513e27f
commit a914456827
7 changed files with 1043 additions and 14 deletions

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

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

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

View File

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

View File

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

View File

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

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