mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-07 22:44:49 +00:00
Compare commits
3 Commits
cloud/1.44
...
feat/Remot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09942a5b7f | ||
|
|
28c97d3687 | ||
|
|
97c2a0d364 |
@@ -2700,6 +2700,12 @@
|
||||
"placeholderUnknown": "Select media...",
|
||||
"maxSelectionReached": "Maximum selection limit reached"
|
||||
},
|
||||
"remoteCombo": {
|
||||
"loading": "Loading...",
|
||||
"loadFailed": "Failed to load options",
|
||||
"playAudioPreview": "Play audio preview",
|
||||
"pauseAudioPreview": "Pause audio preview"
|
||||
},
|
||||
"valueControl": {
|
||||
"header": {
|
||||
"prefix": "Automatically update the value",
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import axios, { AxiosError, AxiosHeaders } from 'axios'
|
||||
import type * as AxiosModule from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type {
|
||||
RemoteComboConfig,
|
||||
RemoteItemSchema
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
// Preserve everything axios exports — only `default.get` is the call site we
|
||||
// drive. Other modules in the import graph (e.g. workspaceApi) call
|
||||
// axios.create() at module-load time, so we can't replace the default outright.
|
||||
vi.mock('axios', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AxiosModule>()
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual.default, get: vi.fn() }
|
||||
}
|
||||
})
|
||||
|
||||
// All four auth-related composables are mocked at module level so the SFC's
|
||||
// imports never pull in firebase / vuefire. Their return shapes only need to
|
||||
// satisfy the call sites the widget actually hits.
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: { teamWorkspacesEnabled: false } })
|
||||
}))
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
|
||||
}))
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
userId: undefined,
|
||||
getAuthHeader: vi.fn(() => Promise.resolve(null))
|
||||
})
|
||||
}))
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: () => ({ getApiKey: () => null })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
// Minimal stub: surfaces the props the widget binds (so we can assert on them)
|
||||
// and exposes click affordances that emit `update:selected` for the user-action
|
||||
// tests. The real FormDropdown's rendering is tested in its own suite.
|
||||
const FormDropdownStub = {
|
||||
name: 'FormDropdown',
|
||||
props: [
|
||||
'selected',
|
||||
'items',
|
||||
'placeholder',
|
||||
'multiple',
|
||||
'showSort',
|
||||
'showLayoutSwitcher',
|
||||
'searcher',
|
||||
'layoutMode'
|
||||
],
|
||||
emits: ['update:selected', 'update:layoutMode'],
|
||||
template: `
|
||||
<div data-testid="dropdown">
|
||||
<span data-testid="placeholder">{{ placeholder }}</span>
|
||||
<span data-testid="items-count">{{ items.length }}</span>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:data-testid="'item-' + item.id"
|
||||
@click="$emit('update:selected', new Set([item.id]))"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
<button
|
||||
data-testid="deselect"
|
||||
@click="$emit('update:selected', new Set())"
|
||||
>×</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const baseSchema: RemoteItemSchema = {
|
||||
value_field: 'id',
|
||||
label_field: 'name',
|
||||
preview_type: 'image'
|
||||
}
|
||||
|
||||
function buildWidget(
|
||||
remoteCombo: Partial<Omit<RemoteComboConfig, 'route' | 'item_schema'>> = {},
|
||||
value: string | undefined = undefined
|
||||
): SimplifiedWidget<string | undefined> {
|
||||
const spec: ComboInputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'voice',
|
||||
remote_combo: {
|
||||
route: '/voices',
|
||||
item_schema: baseSchema,
|
||||
...remoteCombo
|
||||
}
|
||||
}
|
||||
return createMockWidget<string | undefined>({
|
||||
name: 'voice',
|
||||
type: 'COMBO',
|
||||
value,
|
||||
spec
|
||||
})
|
||||
}
|
||||
|
||||
function renderWidget(
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined = undefined
|
||||
) {
|
||||
return render(RichComboWidget, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue: modelValue ?? widget.value
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia(), i18n],
|
||||
stubs: { FormDropdown: FormDropdownStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mockAxiosResponseOnce(data: unknown) {
|
||||
vi.mocked(axios.get).mockResolvedValueOnce({ data })
|
||||
}
|
||||
|
||||
function mockAxiosErrorOnce(status: number) {
|
||||
vi.mocked(axios.get).mockRejectedValueOnce(
|
||||
new AxiosError(`HTTP ${status}`, 'ERR_BAD_RESPONSE', undefined, undefined, {
|
||||
status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: null
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function mockAxiosNetworkErrorOnce() {
|
||||
vi.mocked(axios.get).mockRejectedValueOnce(
|
||||
new AxiosError('Network Error', 'ERR_NETWORK')
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Cache API isn't in happy-dom by default. Stub a no-op cache so getCached
|
||||
// always returns null (forces a fetch) and setCache/clearCache resolve.
|
||||
vi.stubGlobal('caches', {
|
||||
open: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
match: vi.fn(() => Promise.resolve(undefined)),
|
||||
put: vi.fn(() => Promise.resolve()),
|
||||
delete: vi.fn(() => Promise.resolve(true))
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('RichComboWidget', () => {
|
||||
it('mounts, fetches, and renders the items returned from the route', async () => {
|
||||
mockAxiosResponseOnce([
|
||||
{ id: 'a', name: 'Alice' },
|
||||
{ id: 'b', name: 'Bob' }
|
||||
])
|
||||
|
||||
renderWidget(buildWidget())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('items-count').textContent).toBe('2')
|
||||
)
|
||||
expect(screen.getByText('Alice')).toBeTruthy()
|
||||
expect(screen.getByText('Bob')).toBeTruthy()
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the load-failed placeholder on a non-retriable 404 without retrying', async () => {
|
||||
mockAxiosErrorOnce(404)
|
||||
|
||||
renderWidget(buildWidget())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('placeholder').textContent).toBe(
|
||||
'widgets.remoteCombo.loadFailed'
|
||||
)
|
||||
)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the load-failed placeholder when retries are exhausted', async () => {
|
||||
// max_retries=1 lets us assert exhaustion without sleeping through the
|
||||
// exponential backoff (`attempts++` then `attempts >= maxRetries` breaks
|
||||
// before any setTimeout call).
|
||||
mockAxiosNetworkErrorOnce()
|
||||
|
||||
renderWidget(buildWidget({ max_retries: 1 }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('placeholder').textContent).toBe(
|
||||
'widgets.remoteCombo.loadFailed'
|
||||
)
|
||||
)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refetches when the refresh button is clicked', async () => {
|
||||
mockAxiosResponseOnce([{ id: 'a', name: 'Alice' }])
|
||||
|
||||
renderWidget(buildWidget())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('items-count').textContent).toBe('1')
|
||||
)
|
||||
|
||||
mockAxiosResponseOnce([
|
||||
{ id: 'a', name: 'Alice' },
|
||||
{ id: 'b', name: 'Bob' }
|
||||
])
|
||||
await userEvent.click(screen.getByLabelText('g.refresh'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('items-count').textContent).toBe('2')
|
||||
)
|
||||
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('clears modelValue to undefined when the selected item is toggled off (B1 regression)', async () => {
|
||||
mockAxiosResponseOnce([{ id: 'a', name: 'Alice' }])
|
||||
|
||||
const { emitted } = renderWidget(buildWidget(), 'a')
|
||||
|
||||
expect(await screen.findByTestId('item-a')).toBeTruthy()
|
||||
|
||||
await userEvent.click(screen.getByTestId('deselect'))
|
||||
|
||||
const updates = emitted('update:modelValue')
|
||||
expect(updates).toBeTruthy()
|
||||
expect(updates!.at(-1)).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('preserves a stale modelValue when the fetched items do not contain that id', async () => {
|
||||
mockAxiosResponseOnce([
|
||||
{ id: 'a', name: 'Alice' },
|
||||
{ id: 'b', name: 'Bob' }
|
||||
])
|
||||
|
||||
const { emitted } = renderWidget(buildWidget(), 'stale-id')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('items-count').textContent).toBe('2')
|
||||
)
|
||||
|
||||
// The selection sync watcher only mutates the internal selectedSet — it
|
||||
// never writes to modelValue, so the stale id round-trips intact when the
|
||||
// workflow is later saved.
|
||||
expect(emitted('update:modelValue')).toBeFalsy()
|
||||
expect(screen.getByTestId('placeholder').textContent).toBe(
|
||||
'widgets.uploadSelect.placeholder'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type {
|
||||
RemoteComboConfig,
|
||||
RemoteItemSchema
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import FormDropdown from './form/dropdown/FormDropdown.vue'
|
||||
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
|
||||
import { AssetKindKey } from './form/dropdown/types'
|
||||
import {
|
||||
buildSearchText,
|
||||
extractItems,
|
||||
getByPath,
|
||||
mapToDropdownItem
|
||||
} from '../utils/itemSchemaUtils'
|
||||
import { fetchRemoteRoute } from '../utils/fetchRemoteRoute'
|
||||
import {
|
||||
buildCacheKey,
|
||||
getBackoff,
|
||||
isRetriableError,
|
||||
summarizeError,
|
||||
summarizePayload
|
||||
} from '../utils/richComboHelpers'
|
||||
|
||||
const DEFAULT_MAX_RETRIES = 5
|
||||
const DEFAULT_TIMEOUT = 30000
|
||||
|
||||
// --- Persistent cache using browser Cache API (survives page reloads) ---
|
||||
const CACHE_NAME = 'comfy-remote-widget'
|
||||
|
||||
// Mirrors useAuthStore().getAuthHeader()'s priority chain so the cache is
|
||||
// partitioned by the *active* auth context, not just the firebase user.
|
||||
// Same firebase user across two workspaces, or across workspace ↔ personal,
|
||||
// would otherwise share a cache and bleed data.
|
||||
//
|
||||
// Returns an opaque, non-secret identifier. The API-key branch deliberately
|
||||
// returns a constant rather than the key value or a hash of it: hashing is
|
||||
// async (SubtleCrypto), and grouping all keys on one machine under a single
|
||||
// scope is an acceptable tradeoff for the rare key-rotation case.
|
||||
function getAuthScope(): string {
|
||||
const { flags } = useFeatureFlags()
|
||||
if (flags.teamWorkspacesEnabled) {
|
||||
const wsId = useWorkspaceAuthStore().currentWorkspace?.id
|
||||
if (wsId) return `ws:${wsId}`
|
||||
}
|
||||
const uid = useAuthStore().userId
|
||||
if (uid) return `fb:${uid}`
|
||||
return useApiKeyAuthStore().getApiKey() ? 'apikey' : 'anon'
|
||||
}
|
||||
|
||||
function cacheKeyFor(config: RemoteComboConfig): string {
|
||||
return buildCacheKey(config, getAuthScope())
|
||||
}
|
||||
|
||||
async function getCached(config: RemoteComboConfig): Promise<unknown[] | null> {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME)
|
||||
const resp = await cache.match(cacheKeyFor(config))
|
||||
if (!resp) return null
|
||||
const entry = await resp.json()
|
||||
const ttl = config.refresh
|
||||
if (!ttl || ttl <= 0) return entry.data
|
||||
if (Date.now() - entry.timestamp < ttl) return entry.data
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCache(config: RemoteComboConfig) {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME)
|
||||
await cache.delete(cacheKeyFor(config))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function setCache(config: RemoteComboConfig, data: unknown[]) {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME)
|
||||
const body = JSON.stringify({ data, timestamp: Date.now() })
|
||||
await cache.put(cacheKeyFor(config), new Response(body))
|
||||
} catch {
|
||||
// Cache API unavailable — widget still works, just no persistence
|
||||
}
|
||||
}
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const comboSpec = computed(() => {
|
||||
if (widget.spec && isComboInputSpec(widget.spec)) {
|
||||
return widget.spec
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const remoteConfig = computed<RemoteComboConfig | undefined>(
|
||||
() => comboSpec.value?.remote_combo
|
||||
)
|
||||
const itemSchema = computed<RemoteItemSchema | undefined>(
|
||||
() => remoteConfig.value?.item_schema
|
||||
)
|
||||
|
||||
// --- Fetch state ---
|
||||
const rawItems = ref<unknown[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let abortController: AbortController | undefined
|
||||
|
||||
// --- Auto-select policy ---
|
||||
// Only sets modelValue when it's empty; never overrides an existing value
|
||||
// (valid or stale) — user intent and workflow portability are preserved.
|
||||
function applyAutoSelect(config: RemoteComboConfig) {
|
||||
if (modelValue.value) return
|
||||
|
||||
const list = items.value
|
||||
if (list.length === 0) return
|
||||
|
||||
if (config.auto_select === 'first') {
|
||||
modelValue.value = list[0].id
|
||||
} else if (config.auto_select === 'last') {
|
||||
modelValue.value = list[list.length - 1].id
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAll(config: RemoteComboConfig) {
|
||||
const controller = abortController!
|
||||
const maxRetries = config.max_retries ?? DEFAULT_MAX_RETRIES
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
let attempts = 0
|
||||
while (!controller.signal.aborted) {
|
||||
try {
|
||||
const res = await fetchRemoteRoute(config.route, {
|
||||
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
||||
signal: controller.signal
|
||||
})
|
||||
if (controller.signal.aborted) return
|
||||
const fetchedItems = extractItems(res.data, config.response_key)
|
||||
if (fetchedItems === null) {
|
||||
console.error('RichComboWidget: expected array response', {
|
||||
route: config.route,
|
||||
responseKey: config.response_key,
|
||||
received: summarizePayload(res.data)
|
||||
})
|
||||
error.value = t('widgets.remoteCombo.loadFailed')
|
||||
break
|
||||
}
|
||||
await setCache(config, fetchedItems)
|
||||
if (controller.signal.aborted) return
|
||||
rawItems.value = fetchedItems
|
||||
applyAutoSelect(config)
|
||||
break
|
||||
} catch (err: unknown) {
|
||||
if (controller.signal.aborted) return
|
||||
console.error('RichComboWidget: fetch error', {
|
||||
route: config.route,
|
||||
error: summarizeError(err)
|
||||
})
|
||||
if (!isRetriableError(err)) {
|
||||
error.value = t('widgets.remoteCombo.loadFailed')
|
||||
break
|
||||
}
|
||||
attempts++
|
||||
if (attempts >= maxRetries) {
|
||||
error.value = t('widgets.remoteCombo.loadFailed')
|
||||
break
|
||||
}
|
||||
const delay = getBackoff(attempts)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchItems(bypassCache = false) {
|
||||
const config = remoteConfig.value
|
||||
if (!config) return
|
||||
|
||||
// Claim the active controller before any async work so the cache-hit
|
||||
// path can bail out if a later fetchItems supersedes us.
|
||||
abortController?.abort()
|
||||
const myController = new AbortController()
|
||||
abortController = myController
|
||||
|
||||
// Check cache first (unless manual refresh)
|
||||
if (!bypassCache) {
|
||||
const cached = await getCached(config)
|
||||
if (myController.signal.aborted) return
|
||||
if (cached) {
|
||||
rawItems.value = cached
|
||||
applyAutoSelect(config)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Reset items for fresh fetch
|
||||
rawItems.value = []
|
||||
await fetchAll(config)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchItems()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
abortController?.abort()
|
||||
})
|
||||
|
||||
// --- Preview type ---
|
||||
const assetKind = computed(() => itemSchema.value?.preview_type ?? 'image')
|
||||
|
||||
provide(AssetKindKey, assetKind)
|
||||
|
||||
// --- Item mapping ---
|
||||
const items = computed<FormDropdownItem[]>(() => {
|
||||
const schema = itemSchema.value
|
||||
if (schema) {
|
||||
return rawItems.value.map((raw) => mapToDropdownItem(raw, schema))
|
||||
}
|
||||
return rawItems.value.map((raw) => {
|
||||
const val = String(raw ?? '')
|
||||
return { id: val, name: val }
|
||||
})
|
||||
})
|
||||
|
||||
// --- Search ---
|
||||
const searchIndex = computed(() => {
|
||||
const schema = itemSchema.value
|
||||
const fields = schema?.search_fields
|
||||
if (!schema || !fields?.length) return new Map<string, string>()
|
||||
const index = new Map<string, string>()
|
||||
for (const raw of rawItems.value) {
|
||||
const id = String(getByPath(raw, schema.value_field) ?? '')
|
||||
const text = buildSearchText(raw, fields)
|
||||
if (text) index.set(id, text)
|
||||
}
|
||||
return index
|
||||
})
|
||||
|
||||
const layoutMode = ref<LayoutMode>('list')
|
||||
const selectedSet = ref<Set<string>>(new Set())
|
||||
|
||||
async function searcher(query: string, searchItems: FormDropdownItem[]) {
|
||||
if (!query.trim()) return searchItems
|
||||
const q = query.toLowerCase()
|
||||
return searchItems.filter((item) => {
|
||||
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
|
||||
return text.includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Selection sync ---
|
||||
watch(
|
||||
[modelValue, items],
|
||||
([val]) => {
|
||||
selectedSet.value.clear()
|
||||
if (val) {
|
||||
const item = items.value.find((i) => i.id === val)
|
||||
if (item) selectedSet.value.add(item.id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleRefresh() {
|
||||
abortController?.abort()
|
||||
error.value = null
|
||||
const config = remoteConfig.value
|
||||
// Sequence the cache delete before the refetch: otherwise the (very fast)
|
||||
// setCache from a quickly-resolved network response can land the new entry
|
||||
// before the still-pending cache.delete removes it, silently dropping the
|
||||
// freshly-cached data on the next mount.
|
||||
void (async () => {
|
||||
if (config) await clearCache(config)
|
||||
await fetchItems(true)
|
||||
})()
|
||||
}
|
||||
|
||||
function handleSelection(selected: Set<string>) {
|
||||
modelValue.value = selected.values().next().value
|
||||
}
|
||||
|
||||
const placeholder = computed(() => {
|
||||
if (loading.value) return t('widgets.remoteCombo.loading')
|
||||
if (error.value) return error.value
|
||||
return t('widgets.uploadSelect.placeholder')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full min-w-0 items-center gap-1 rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<FormDropdown
|
||||
v-model:selected="selectedSet"
|
||||
v-model:layout-mode="layoutMode"
|
||||
:items="items"
|
||||
:placeholder="placeholder"
|
||||
:multiple="false"
|
||||
:show-sort="false"
|
||||
:show-layout-switcher="false"
|
||||
:searcher="searcher"
|
||||
class="min-w-0 flex-1"
|
||||
@update:selected="handleSelection"
|
||||
/>
|
||||
<button
|
||||
v-if="remoteConfig?.refresh_button !== false"
|
||||
type="button"
|
||||
:aria-label="t('g.refresh')"
|
||||
:title="t('g.refresh')"
|
||||
class="text-secondary flex size-7 shrink-0 items-center justify-center rounded-sm hover:bg-component-node-widget-background-hovered"
|
||||
@click.stop="handleRefresh"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn('icon-[lucide--refresh-cw] size-3.5', loading && 'animate-spin')
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<RichComboWidget v-if="hasRemoteCombo" v-model="modelValue" :widget />
|
||||
<WidgetSelectDropdown
|
||||
v-if="isDropdownUIWidget"
|
||||
v-else-if="isDropdownUIWidget"
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
:node-type="widget.nodeType ?? nodeType"
|
||||
@@ -24,6 +25,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
|
||||
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
||||
@@ -53,6 +55,8 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const hasRemoteCombo = computed(() => !!comboSpec.value?.remote_combo)
|
||||
|
||||
const specDescriptor = computed<{
|
||||
kind: AssetKind
|
||||
allowUpload: boolean
|
||||
|
||||
@@ -33,6 +33,8 @@ interface Props {
|
||||
accept?: string
|
||||
filterOptions?: FilterOption[]
|
||||
sortOptions?: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -59,6 +61,8 @@ const {
|
||||
accept,
|
||||
filterOptions = [],
|
||||
sortOptions = getDefaultSortOptions(),
|
||||
showSort = true,
|
||||
showLayoutSwitcher = true,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
@@ -229,6 +233,8 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:filter-options
|
||||
:sort-options
|
||||
:show-sort
|
||||
:show-layout-switcher
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
|
||||
@@ -68,7 +68,11 @@ const theButtonStyle = computed(() =>
|
||||
{{ placeholder }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
|
||||
{{
|
||||
selectedItems
|
||||
.map((item) => item.label || item.name || item.id)
|
||||
.join(', ')
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<i
|
||||
|
||||
@@ -20,6 +20,8 @@ interface Props {
|
||||
isSelected: (item: FormDropdownItem, index: number) => boolean
|
||||
filterOptions: FilterOption[]
|
||||
sortOptions: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -31,6 +33,8 @@ const {
|
||||
isSelected,
|
||||
filterOptions,
|
||||
sortOptions,
|
||||
showSort = true,
|
||||
showLayoutSwitcher = true,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
@@ -112,6 +116,8 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
v-model:ownership-selected="ownershipSelected"
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:sort-options
|
||||
:show-sort
|
||||
:show-layout-switcher
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
@@ -145,6 +151,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
:preview-url="item.preview_url ?? ''"
|
||||
:name="item.name"
|
||||
:label="item.label"
|
||||
:description="item.description"
|
||||
:layout="layoutMode"
|
||||
@click="emit('item-click', item, index)"
|
||||
/>
|
||||
|
||||
@@ -16,8 +16,10 @@ import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
const { showSort = true, showLayoutSwitcher = true } = defineProps<{
|
||||
sortOptions: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -112,6 +114,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="showSort"
|
||||
ref="sortTriggerRef"
|
||||
:aria-label="t('assetBrowser.sortBy')"
|
||||
:title="t('assetBrowser.sortBy')"
|
||||
@@ -132,6 +135,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||
</Button>
|
||||
<Popover
|
||||
v-if="showSort"
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
@@ -306,6 +310,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
</Popover>
|
||||
|
||||
<div
|
||||
v-if="showLayoutSwitcher"
|
||||
:class="
|
||||
cn(
|
||||
actionButtonStyle,
|
||||
|
||||
@@ -28,11 +28,15 @@ const assetKind = inject(AssetKindKey)
|
||||
|
||||
const isVideo = computed(() => assetKind?.value === 'video')
|
||||
const isMesh = computed(() => assetKind?.value === 'mesh')
|
||||
const isAudio = computed(() => assetKind?.value === 'audio')
|
||||
|
||||
const mediaContainerRef = ref<HTMLElement>()
|
||||
const resolvedMeshPreview = ref<string | null>(null)
|
||||
const meshPreviewAttempted = ref(false)
|
||||
|
||||
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||
const isPlayingAudio = ref(false)
|
||||
|
||||
function toLookupName(name: string): string {
|
||||
const stripped = name.replace(/ \[output\]$/, '')
|
||||
const slash = stripped.lastIndexOf('/')
|
||||
@@ -68,6 +72,17 @@ function handleClick() {
|
||||
emit('click', props.index)
|
||||
}
|
||||
|
||||
function toggleAudioPreview(event: Event) {
|
||||
event.stopPropagation()
|
||||
const audio = audioRef.value
|
||||
if (!audio) return
|
||||
if (audio.paused) {
|
||||
void audio.play().catch(() => {})
|
||||
} else {
|
||||
audio.pause()
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
emit('mediaLoad', event)
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
@@ -148,6 +163,35 @@ function handleVideoLoad(event: Event) {
|
||||
muted
|
||||
@loadeddata="handleVideoLoad"
|
||||
/>
|
||||
<button
|
||||
v-else-if="previewUrl && isAudio"
|
||||
type="button"
|
||||
:aria-label="
|
||||
isPlayingAudio
|
||||
? t('widgets.remoteCombo.pauseAudioPreview')
|
||||
: t('widgets.remoteCombo.playAudioPreview')
|
||||
"
|
||||
:aria-pressed="isPlayingAudio"
|
||||
class="flex size-full cursor-pointer items-center justify-center bg-component-node-widget-background hover:bg-component-node-widget-background-hovered"
|
||||
@click.stop="toggleAudioPreview"
|
||||
>
|
||||
<audio
|
||||
ref="audioRef"
|
||||
:src="previewUrl"
|
||||
preload="none"
|
||||
@play="isPlayingAudio = true"
|
||||
@pause="isPlayingAudio = false"
|
||||
@ended="isPlayingAudio = false"
|
||||
/>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'text-secondary size-5',
|
||||
isPlayingAudio ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<img
|
||||
v-else-if="displayedPreviewUrl"
|
||||
:src="displayedPreviewUrl"
|
||||
@@ -193,6 +237,13 @@ function handleVideoLoad(event: Event) {
|
||||
>
|
||||
{{ label ?? name }}
|
||||
</span>
|
||||
<!-- Description -->
|
||||
<span
|
||||
v-if="description && layout !== 'grid'"
|
||||
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
<!-- Meta Data -->
|
||||
<span v-if="actualDimensions" class="text-secondary block text-xs">
|
||||
{{ actualDimensions }}
|
||||
|
||||
@@ -12,7 +12,9 @@ export interface FormDropdownItem {
|
||||
name: string
|
||||
/** Original/alternate label (e.g., original filename) */
|
||||
label?: string
|
||||
/** Preview image/video URL */
|
||||
/** Short description shown below the name in list view */
|
||||
description?: string
|
||||
/** Preview image/video/audio URL */
|
||||
preview_url?: string
|
||||
/** Whether the item is immutable (public model) - used for ownership filtering */
|
||||
is_immutable?: boolean
|
||||
@@ -47,6 +49,7 @@ export interface FormDropdownMenuItemProps {
|
||||
previewUrl: string
|
||||
name: string
|
||||
label?: string
|
||||
description?: string
|
||||
layout?: LayoutMode
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import axios from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchRemoteRoute } from '@/renderer/extensions/vueNodes/widgets/utils/fetchRemoteRoute'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
|
||||
const COMFY_API_BASE = 'https://api.example.test'
|
||||
|
||||
const mockAuth = vi.hoisted(() => ({
|
||||
authHeader: null as AuthHeader | null
|
||||
}))
|
||||
|
||||
vi.mock('axios', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof axios>()
|
||||
return {
|
||||
default: {
|
||||
...actual,
|
||||
get: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
getComfyApiBaseUrl: () => COMFY_API_BASE
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: vi.fn(() => Promise.resolve(mockAuth.authHeader))
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('fetchRemoteRoute', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockAuth.authHeader = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('prepends the comfy api base URL to the route', async () => {
|
||||
await fetchRemoteRoute('/voices')
|
||||
const [url] = vi.mocked(axios.get).mock.calls[0]
|
||||
expect(url).toBe(`${COMFY_API_BASE}/voices`)
|
||||
})
|
||||
|
||||
it('injects the auth header when one is available', async () => {
|
||||
mockAuth.authHeader = { Authorization: 'Bearer token-123' }
|
||||
await fetchRemoteRoute('/voices')
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0]
|
||||
expect(config?.headers).toEqual({ Authorization: 'Bearer token-123' })
|
||||
})
|
||||
|
||||
it('does not set headers when no auth header is available', async () => {
|
||||
mockAuth.authHeader = null
|
||||
await fetchRemoteRoute('/voices')
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0]
|
||||
expect(config?.headers).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards params, timeout and signal to axios', async () => {
|
||||
const controller = new AbortController()
|
||||
await fetchRemoteRoute('/voices', {
|
||||
params: { filter: 'pro', limit: '10' },
|
||||
timeout: 5000,
|
||||
signal: controller.signal
|
||||
})
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0]
|
||||
expect(config?.params).toEqual({ filter: 'pro', limit: '10' })
|
||||
expect(config?.timeout).toBe(5000)
|
||||
expect(config?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('returns the axios response', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValueOnce({ data: { items: [1, 2] } })
|
||||
const res = await fetchRemoteRoute('/voices')
|
||||
expect(res.data).toEqual({ items: [1, 2] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
|
||||
/**
|
||||
* Resolve a RemoteComboOptions route to a full URL. Routes are always relative
|
||||
* paths and are prepended with the comfy-api base URL.
|
||||
*/
|
||||
function resolveRoute(route: string): string {
|
||||
return getComfyApiBaseUrl() + route
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth headers for a remote request. Always injected — comfy-api requires it.
|
||||
*/
|
||||
async function getRemoteAuthHeaders(): Promise<{ headers?: AuthHeader }> {
|
||||
const authStore = useAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
if (authHeader) {
|
||||
return { headers: authHeader }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: make an authenticated GET request to a remote route.
|
||||
*/
|
||||
export async function fetchRemoteRoute(
|
||||
route: string,
|
||||
options: {
|
||||
params?: Record<string, string>
|
||||
timeout?: number
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
) {
|
||||
const url = resolveRoute(route)
|
||||
const authHeaders = await getRemoteAuthHeaders()
|
||||
return axios.get(url, { ...options, ...authHeaders })
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSearchText,
|
||||
extractItems,
|
||||
getByPath,
|
||||
mapToDropdownItem,
|
||||
resolveLabel
|
||||
} from '@/renderer/extensions/vueNodes/widgets/utils/itemSchemaUtils'
|
||||
|
||||
describe('getByPath', () => {
|
||||
it('returns a top-level value for a plain key', () => {
|
||||
expect(getByPath({ name: 'Alice' }, 'name')).toBe('Alice')
|
||||
})
|
||||
|
||||
it('traverses nested objects via dot-path', () => {
|
||||
expect(getByPath({ profile: { name: 'Alice' } }, 'profile.name')).toBe(
|
||||
'Alice'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats numeric segments as array indices', () => {
|
||||
expect(getByPath({ items: ['a', 'b', 'c'] }, 'items.1')).toBe('b')
|
||||
})
|
||||
|
||||
it('combines nested objects and array indices', () => {
|
||||
const obj = { data: { results: [{ id: 'x' }, { id: 'y' }] } }
|
||||
expect(getByPath(obj, 'data.results.1.id')).toBe('y')
|
||||
})
|
||||
|
||||
it('returns undefined for a missing top-level key', () => {
|
||||
expect(getByPath({ a: 1 }, 'b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when traversing past a null segment', () => {
|
||||
expect(getByPath({ a: null }, 'a.b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when the root is null', () => {
|
||||
expect(getByPath(null, 'a')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when the root is undefined', () => {
|
||||
expect(getByPath(undefined, 'a')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for an out-of-bounds array index', () => {
|
||||
expect(getByPath({ items: ['a'] }, 'items.5')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLabel', () => {
|
||||
it('resolves a plain dot-path to its value', () => {
|
||||
expect(resolveLabel('name', { name: 'Alice' })).toBe('Alice')
|
||||
})
|
||||
|
||||
it('resolves a nested dot-path without placeholders', () => {
|
||||
expect(resolveLabel('profile.name', { profile: { name: 'Alice' } })).toBe(
|
||||
'Alice'
|
||||
)
|
||||
})
|
||||
|
||||
it('substitutes a single {field} placeholder', () => {
|
||||
expect(resolveLabel('Name: {name}', { name: 'Alice' })).toBe('Name: Alice')
|
||||
})
|
||||
|
||||
it('substitutes multiple placeholders', () => {
|
||||
expect(
|
||||
resolveLabel('{first} {last}', { first: 'Alice', last: 'Liddell' })
|
||||
).toBe('Alice Liddell')
|
||||
})
|
||||
|
||||
it('substitutes placeholders with dot-paths', () => {
|
||||
expect(
|
||||
resolveLabel('{profile.name} ({profile.age})', {
|
||||
profile: { name: 'Alice', age: 30 }
|
||||
})
|
||||
).toBe('Alice (30)')
|
||||
})
|
||||
|
||||
it('replaces missing placeholder fields with an empty string', () => {
|
||||
expect(resolveLabel('{name} - {missing}', { name: 'Alice' })).toBe(
|
||||
'Alice - '
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an empty string when a plain path resolves to undefined', () => {
|
||||
expect(resolveLabel('missing', { a: 1 })).toBe('')
|
||||
})
|
||||
|
||||
it('coerces numeric values to strings', () => {
|
||||
expect(resolveLabel('{count}', { count: 5 })).toBe('5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapToDropdownItem', () => {
|
||||
it('maps required fields to id and name', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ voice_id: 'v1', label: 'Roger' },
|
||||
{ value_field: 'voice_id', label_field: 'label', preview_type: 'image' }
|
||||
)
|
||||
|
||||
expect(item).toEqual({
|
||||
id: 'v1',
|
||||
name: 'Roger',
|
||||
description: undefined,
|
||||
preview_url: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('includes description when description_field is set', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'v1', label: 'Roger', desc: 'Laid-back American male' },
|
||||
{
|
||||
value_field: 'id',
|
||||
label_field: 'label',
|
||||
description_field: 'desc',
|
||||
preview_type: 'image'
|
||||
}
|
||||
)
|
||||
|
||||
expect(item.description).toBe('Laid-back American male')
|
||||
})
|
||||
|
||||
it('includes preview_url when preview_url_field is set', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'v1', label: 'Roger', sample: 'https://example.com/a.mp3' },
|
||||
{
|
||||
value_field: 'id',
|
||||
label_field: 'label',
|
||||
preview_url_field: 'sample',
|
||||
preview_type: 'audio'
|
||||
}
|
||||
)
|
||||
|
||||
expect(item.preview_url).toBe('https://example.com/a.mp3')
|
||||
})
|
||||
|
||||
it('resolves label_field templates with placeholders', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 'v1', first: 'Alice', last: 'Liddell' },
|
||||
{
|
||||
value_field: 'id',
|
||||
label_field: '{first} {last}',
|
||||
preview_type: 'image'
|
||||
}
|
||||
)
|
||||
|
||||
expect(item.name).toBe('Alice Liddell')
|
||||
})
|
||||
|
||||
it('resolves dot-path fields for nested data', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ task_result: { elements: [{ element_id: 'e1', name: 'Elem' }] } },
|
||||
{
|
||||
value_field: 'task_result.elements.0.element_id',
|
||||
label_field: 'task_result.elements.0.name',
|
||||
preview_type: 'image'
|
||||
}
|
||||
)
|
||||
|
||||
expect(item.id).toBe('e1')
|
||||
expect(item.name).toBe('Elem')
|
||||
})
|
||||
|
||||
it('stringifies non-string value_field', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ id: 42, label: 'Answer' },
|
||||
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
|
||||
)
|
||||
|
||||
expect(item.id).toBe('42')
|
||||
})
|
||||
|
||||
it('returns an empty string id when value_field is missing', () => {
|
||||
const item = mapToDropdownItem(
|
||||
{ label: 'Orphan' },
|
||||
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
|
||||
)
|
||||
|
||||
expect(item.id).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractItems', () => {
|
||||
it('returns the full response when responseKey is undefined', () => {
|
||||
expect(extractItems([1, 2, 3])).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('extracts items from a top-level key', () => {
|
||||
expect(
|
||||
extractItems({ voices: [{ id: 'a' }, { id: 'b' }] }, 'voices')
|
||||
).toEqual([{ id: 'a' }, { id: 'b' }])
|
||||
})
|
||||
|
||||
it('extracts items via a dot-path', () => {
|
||||
expect(extractItems({ data: { items: [1, 2] } }, 'data.items')).toEqual([
|
||||
1, 2
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty array for a valid empty list', () => {
|
||||
expect(extractItems([])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns null when the path does not exist', () => {
|
||||
expect(extractItems({ a: 1 }, 'nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the path resolves to a non-array', () => {
|
||||
expect(
|
||||
extractItems({ data: { items: 'not an array' } }, 'data.items')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the full response is not an array', () => {
|
||||
expect(extractItems({ not: 'array' })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when response is null', () => {
|
||||
expect(extractItems(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSearchText', () => {
|
||||
it('joins multiple fields with a space', () => {
|
||||
expect(buildSearchText({ a: 'Hello', b: 'World' }, ['a', 'b'])).toBe(
|
||||
'hello world'
|
||||
)
|
||||
})
|
||||
|
||||
it('lowercases the result', () => {
|
||||
expect(buildSearchText({ name: 'ALICE' }, ['name'])).toBe('alice')
|
||||
})
|
||||
|
||||
it('drops missing fields', () => {
|
||||
expect(buildSearchText({ name: 'Alice' }, ['name', 'missing'])).toBe(
|
||||
'alice'
|
||||
)
|
||||
})
|
||||
|
||||
it('supports dot-path fields', () => {
|
||||
expect(
|
||||
buildSearchText({ profile: { name: 'Alice', age: 30 } }, [
|
||||
'profile.name',
|
||||
'profile.age'
|
||||
])
|
||||
).toBe('alice 30')
|
||||
})
|
||||
|
||||
it('returns an empty string when all fields are missing', () => {
|
||||
expect(buildSearchText({ name: 'Alice' }, ['missing'])).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
|
||||
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
|
||||
/** Traverse an object by dot-path, treating numeric segments as array indices */
|
||||
export function getByPath(obj: unknown, path: string): unknown {
|
||||
return path.split('.').reduce((acc: unknown, key: string) => {
|
||||
if (acc == null) return undefined
|
||||
const idx = Number(key)
|
||||
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
|
||||
return (acc as Record<string, unknown>)[key]
|
||||
}, obj)
|
||||
}
|
||||
|
||||
/** Resolve a label — either dot-path or template with {field.path} placeholders */
|
||||
export function resolveLabel(template: string, item: unknown): string {
|
||||
if (!template.includes('{')) {
|
||||
return String(getByPath(item, template) ?? '')
|
||||
}
|
||||
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
|
||||
String(getByPath(item, path) ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
/** Map a raw API object to a FormDropdownItem using the item_schema */
|
||||
export function mapToDropdownItem(
|
||||
raw: unknown,
|
||||
schema: RemoteItemSchema
|
||||
): FormDropdownItem {
|
||||
return {
|
||||
id: String(getByPath(raw, schema.value_field) ?? ''),
|
||||
name: resolveLabel(schema.label_field, raw),
|
||||
description: schema.description_field
|
||||
? resolveLabel(schema.description_field, raw)
|
||||
: undefined,
|
||||
preview_url: schema.preview_url_field
|
||||
? String(getByPath(raw, schema.preview_url_field) ?? '')
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract items array from a full API response using `responseKey`.
|
||||
* Returns `null` when the resolved value isn't an array (path missing,
|
||||
* wrong shape, etc.) so callers can distinguish a malformed response
|
||||
* from a legitimate empty list.
|
||||
*/
|
||||
export function extractItems(
|
||||
response: unknown,
|
||||
responseKey?: string
|
||||
): unknown[] | null {
|
||||
const data = responseKey ? getByPath(response, responseKey) : response
|
||||
return Array.isArray(data) ? data : null
|
||||
}
|
||||
|
||||
/** Build search text for an item from the specified search fields */
|
||||
export function buildSearchText(raw: unknown, searchFields: string[]): string {
|
||||
return searchFields
|
||||
.map((field) => String(getByPath(raw, field) ?? ''))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { AxiosError, AxiosHeaders } from 'axios'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import {
|
||||
buildCacheKey,
|
||||
getBackoff,
|
||||
isRetriableError,
|
||||
summarizeError,
|
||||
summarizePayload
|
||||
} from '@/renderer/extensions/vueNodes/widgets/utils/richComboHelpers'
|
||||
|
||||
const baseConfig: RemoteComboConfig = {
|
||||
route: '/voices',
|
||||
item_schema: {
|
||||
value_field: 'id',
|
||||
label_field: 'name',
|
||||
preview_type: 'image'
|
||||
}
|
||||
}
|
||||
|
||||
function parseKey(key: string): URLSearchParams {
|
||||
return new URL(key).searchParams
|
||||
}
|
||||
|
||||
describe('buildCacheKey', () => {
|
||||
it('encodes the route and response_key', () => {
|
||||
const params = parseKey(
|
||||
buildCacheKey(
|
||||
{
|
||||
...baseConfig,
|
||||
route: '/voices',
|
||||
response_key: 'data.items'
|
||||
},
|
||||
'fb:user-a'
|
||||
)
|
||||
)
|
||||
expect(params.get('route')).toBe('/voices')
|
||||
expect(params.get('responseKey')).toBe('data.items')
|
||||
})
|
||||
|
||||
it('partitions by authScope', () => {
|
||||
const a = buildCacheKey(baseConfig, 'ws:team-a')
|
||||
const b = buildCacheKey(baseConfig, 'ws:team-b')
|
||||
expect(a).not.toBe(b)
|
||||
expect(parseKey(a).get('u')).toBe('ws:team-a')
|
||||
expect(parseKey(b).get('u')).toBe('ws:team-b')
|
||||
})
|
||||
|
||||
it('treats workspace, firebase, and api-key scopes as distinct buckets', () => {
|
||||
const ws = buildCacheKey(baseConfig, 'ws:abc')
|
||||
const fb = buildCacheKey(baseConfig, 'fb:abc')
|
||||
const apikey = buildCacheKey(baseConfig, 'apikey')
|
||||
expect(new Set([ws, fb, apikey]).size).toBe(3)
|
||||
})
|
||||
|
||||
it('falls back to "anon" when authScope is missing', () => {
|
||||
expect(parseKey(buildCacheKey(baseConfig, null)).get('u')).toBe('anon')
|
||||
expect(parseKey(buildCacheKey(baseConfig, undefined)).get('u')).toBe('anon')
|
||||
})
|
||||
|
||||
it('treats missing optional fields as empty so the key stays stable', () => {
|
||||
const params = parseKey(buildCacheKey(baseConfig, 'fb:user-a'))
|
||||
expect(params.get('responseKey')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBackoff', () => {
|
||||
it('grows exponentially from 1s', () => {
|
||||
expect(getBackoff(1)).toBe(2000)
|
||||
expect(getBackoff(2)).toBe(4000)
|
||||
expect(getBackoff(3)).toBe(8000)
|
||||
expect(getBackoff(4)).toBe(16000)
|
||||
})
|
||||
|
||||
it('caps at 16s for higher attempt counts', () => {
|
||||
expect(getBackoff(5)).toBe(16000)
|
||||
expect(getBackoff(10)).toBe(16000)
|
||||
expect(getBackoff(100)).toBe(16000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRetriableError', () => {
|
||||
function axiosErrorWithStatus(status: number): AxiosError {
|
||||
return new AxiosError(
|
||||
`HTTP ${status}`,
|
||||
'ERR_BAD_RESPONSE',
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
it('retries non-axios errors (e.g. unexpected throws)', () => {
|
||||
expect(isRetriableError(new Error('boom'))).toBe(true)
|
||||
expect(isRetriableError('string error')).toBe(true)
|
||||
expect(isRetriableError(undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('retries axios errors with no response (network failures)', () => {
|
||||
const err = new AxiosError('Network Error', 'ERR_NETWORK')
|
||||
expect(isRetriableError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it('retries 5xx responses', () => {
|
||||
expect(isRetriableError(axiosErrorWithStatus(500))).toBe(true)
|
||||
expect(isRetriableError(axiosErrorWithStatus(502))).toBe(true)
|
||||
expect(isRetriableError(axiosErrorWithStatus(503))).toBe(true)
|
||||
})
|
||||
|
||||
it('retries 408 (request timeout) and 429 (too many requests)', () => {
|
||||
expect(isRetriableError(axiosErrorWithStatus(408))).toBe(true)
|
||||
expect(isRetriableError(axiosErrorWithStatus(429))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not retry other 4xx responses', () => {
|
||||
expect(isRetriableError(axiosErrorWithStatus(400))).toBe(false)
|
||||
expect(isRetriableError(axiosErrorWithStatus(401))).toBe(false)
|
||||
expect(isRetriableError(axiosErrorWithStatus(403))).toBe(false)
|
||||
expect(isRetriableError(axiosErrorWithStatus(404))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeError', () => {
|
||||
it('extracts message, code and status from an axios error', () => {
|
||||
const err = new AxiosError(
|
||||
'Request failed',
|
||||
'ERR_BAD_RESPONSE',
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 500,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: null
|
||||
}
|
||||
)
|
||||
expect(summarizeError(err)).toEqual({
|
||||
message: 'Request failed',
|
||||
code: 'ERR_BAD_RESPONSE',
|
||||
status: 500
|
||||
})
|
||||
})
|
||||
|
||||
it('does not include axios config, headers, request or response data', () => {
|
||||
const authedConfig = {
|
||||
url: '/voices',
|
||||
method: 'get',
|
||||
headers: new AxiosHeaders({ Authorization: 'Bearer SECRET-TOKEN-123' })
|
||||
}
|
||||
const err = new AxiosError(
|
||||
'Request failed',
|
||||
'ERR_BAD_RESPONSE',
|
||||
authedConfig,
|
||||
undefined,
|
||||
{
|
||||
status: 500,
|
||||
statusText: '',
|
||||
headers: { 'set-cookie': ['session=PRIVATE'] },
|
||||
config: authedConfig,
|
||||
data: { user_email: 'private@example.com' }
|
||||
}
|
||||
)
|
||||
const summary = summarizeError(err)
|
||||
|
||||
expect(JSON.stringify(summary)).not.toContain('SECRET-TOKEN-123')
|
||||
expect(JSON.stringify(summary)).not.toContain('PRIVATE')
|
||||
expect(JSON.stringify(summary)).not.toContain('private@example.com')
|
||||
expect(summary).not.toHaveProperty('config')
|
||||
expect(summary).not.toHaveProperty('request')
|
||||
expect(summary).not.toHaveProperty('response')
|
||||
})
|
||||
|
||||
it('reports an axios network error with no response as undefined status', () => {
|
||||
const err = new AxiosError('Network Error', 'ERR_NETWORK')
|
||||
expect(summarizeError(err)).toEqual({
|
||||
message: 'Network Error',
|
||||
code: 'ERR_NETWORK',
|
||||
status: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes a plain Error using its name and message', () => {
|
||||
expect(summarizeError(new TypeError('boom'))).toEqual({
|
||||
message: 'boom',
|
||||
name: 'TypeError'
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces non-Error throwables to a message string', () => {
|
||||
expect(summarizeError('oops')).toEqual({ message: 'oops' })
|
||||
expect(summarizeError(42)).toEqual({ message: '42' })
|
||||
expect(summarizeError(null)).toEqual({ message: 'null' })
|
||||
expect(summarizeError(undefined)).toEqual({ message: 'undefined' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizePayload', () => {
|
||||
it('reports array length without exposing values', () => {
|
||||
expect(
|
||||
summarizePayload([{ secret: 'a' }, { secret: 'b' }, { secret: 'c' }])
|
||||
).toEqual({
|
||||
type: 'array',
|
||||
length: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('reports object keys without exposing values', () => {
|
||||
expect(
|
||||
summarizePayload({ user_email: 'private@example.com', voices: ['x'] })
|
||||
).toEqual({
|
||||
type: 'object',
|
||||
keys: ['user_email', 'voices'],
|
||||
keyCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('caps the keys sample at 10 but reports the full key count', () => {
|
||||
const big: Record<string, number> = {}
|
||||
for (let i = 0; i < 25; i++) big[`k${i}`] = i
|
||||
const summary = summarizePayload(big) as {
|
||||
type: string
|
||||
keys: string[]
|
||||
keyCount: number
|
||||
}
|
||||
expect(summary.type).toBe('object')
|
||||
expect(summary.keys).toHaveLength(10)
|
||||
expect(summary.keyCount).toBe(25)
|
||||
})
|
||||
|
||||
it('distinguishes null and undefined', () => {
|
||||
expect(summarizePayload(null)).toEqual({ type: 'null' })
|
||||
expect(summarizePayload(undefined)).toEqual({ type: 'undefined' })
|
||||
})
|
||||
|
||||
it('reports primitive types without their value', () => {
|
||||
expect(summarizePayload('hello')).toEqual({ type: 'string' })
|
||||
expect(summarizePayload(123)).toEqual({ type: 'number' })
|
||||
expect(summarizePayload(true)).toEqual({ type: 'boolean' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
|
||||
|
||||
const BACKOFF_BASE_MS = 1000
|
||||
const BACKOFF_CAP_MS = 16000
|
||||
|
||||
/**
|
||||
* Build a stable cache key for a remote combo configuration.
|
||||
*
|
||||
* All remote-combo routes go through comfy-api with auth, so the cache is
|
||||
* always partitioned by `authScope` — an opaque, non-secret identifier of
|
||||
* the active auth context (workspace id, firebase uid, etc.). Resolving the
|
||||
* scope is the caller's responsibility, which keeps this helper pure and
|
||||
* trivially testable.
|
||||
*/
|
||||
export function buildCacheKey(
|
||||
config: RemoteComboConfig,
|
||||
authScope: string | null | undefined
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
route: config.route,
|
||||
responseKey: config.response_key ?? '',
|
||||
u: authScope ?? 'anon'
|
||||
})
|
||||
return `https://cache.comfy.invalid/?${params}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential backoff in milliseconds, capped at 16s. `count` is the
|
||||
* number of failed attempts so far (1-indexed for the first retry).
|
||||
*/
|
||||
export function getBackoff(count: number): number {
|
||||
return Math.min(BACKOFF_BASE_MS * Math.pow(2, count), BACKOFF_CAP_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish transient errors (worth retrying) from permanent ones.
|
||||
* 401/403/404 etc. won't fix themselves — retrying wastes time.
|
||||
* Network-level failures (no response) are treated as retriable.
|
||||
*/
|
||||
export function isRetriableError(err: unknown): boolean {
|
||||
if (!axios.isAxiosError(err)) return true
|
||||
const status = err.response?.status
|
||||
if (status == null) return true
|
||||
if (status >= 500) return true
|
||||
return status === 408 || status === 429
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a console-safe summary of an unknown error. Authenticated remote
|
||||
* routes inject auth headers via fetchRemoteRoute, and AxiosError serializes
|
||||
* its `config` (including those headers) by default — so logging the raw
|
||||
* error would leak bearer tokens to devtools and any attached telemetry.
|
||||
* This summary keeps only the diagnostic essentials.
|
||||
*/
|
||||
export function summarizeError(err: unknown): Record<string, unknown> {
|
||||
if (axios.isAxiosError(err)) {
|
||||
return {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
status: err.response?.status
|
||||
}
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { message: err.message, name: err.name }
|
||||
}
|
||||
return { message: String(err) }
|
||||
}
|
||||
|
||||
const PAYLOAD_KEY_SAMPLE = 10
|
||||
|
||||
/**
|
||||
* Build a console-safe summary of a remote response payload. Logs the
|
||||
* structural shape so devs can diagnose schema mismatches without the
|
||||
* actual values, which for authenticated routes may contain private data.
|
||||
*/
|
||||
export function summarizePayload(data: unknown): Record<string, unknown> {
|
||||
if (data === null) return { type: 'null' }
|
||||
if (data === undefined) return { type: 'undefined' }
|
||||
if (Array.isArray(data)) return { type: 'array', length: data.length }
|
||||
if (typeof data === 'object') {
|
||||
const keys = Object.keys(data as Record<string, unknown>)
|
||||
return {
|
||||
type: 'object',
|
||||
keys: keys.slice(0, PAYLOAD_KEY_SAMPLE),
|
||||
keyCount: keys.length
|
||||
}
|
||||
}
|
||||
return { type: typeof data }
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import { resultItemType } from '@/schemas/apiSchema'
|
||||
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
|
||||
/**
|
||||
* Plain remote combo config — feeds a standard combo dropdown from a remote endpoint.
|
||||
* Handled by `useRemoteWidget` + `WidgetSelectDropdown`.
|
||||
*/
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
route: z.string().url().or(z.string().startsWith('/')),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
@@ -15,6 +20,32 @@ const zRemoteWidgetConfig = z.object({
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
|
||||
const zRemoteItemSchema = z.object({
|
||||
value_field: z.string(),
|
||||
label_field: z.string(),
|
||||
preview_url_field: z.string().optional(),
|
||||
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
|
||||
description_field: z.string().optional(),
|
||||
search_fields: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Rich remote combo config — feeds `RichComboWidget` with item previews, search, and filtering.
|
||||
* Requires `item_schema`. Vue-nodes only. Routes are always relative paths and resolve against
|
||||
* the comfy-api base URL with auth headers injected. The endpoint returns the full items array
|
||||
* in a single response.
|
||||
*/
|
||||
const zRemoteComboConfig = z.object({
|
||||
route: z.string().startsWith('/'),
|
||||
item_schema: zRemoteItemSchema,
|
||||
refresh_button: z.boolean().optional(),
|
||||
auto_select: z.enum(['first', 'last']).optional(),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
response_key: z.string().optional(),
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
const zMultiSelectOption = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
chip: z.boolean().optional()
|
||||
@@ -96,6 +127,7 @@ export const zComboInputOptions = zBaseInputOptions.extend({
|
||||
animated_image_upload: z.boolean().optional(),
|
||||
options: z.array(zComboOption).optional(),
|
||||
remote: zRemoteWidgetConfig.optional(),
|
||||
remote_combo: zRemoteComboConfig.optional(),
|
||||
/** Whether the widget is a multi-select widget. */
|
||||
multi_select: zMultiSelectOption.optional()
|
||||
})
|
||||
@@ -352,7 +384,9 @@ export const zMatchTypeOptions = z.object({
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
|
||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||
export type RemoteComboConfig = z.infer<typeof zRemoteComboConfig>
|
||||
|
||||
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
||||
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>
|
||||
|
||||
@@ -71,4 +71,66 @@ describe('validateNodeDef', () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
describe('remote_combo route validation', () => {
|
||||
const buildNodeDef = (remoteCombo: object): unknown => ({
|
||||
...EXAMPLE_NODE_DEF,
|
||||
input: {
|
||||
required: {
|
||||
voice: ['COMBO', { remote_combo: remoteCombo }]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const baseRemoteCombo = {
|
||||
item_schema: { value_field: 'id', label_field: 'name' }
|
||||
}
|
||||
|
||||
it('accepts a relative route', () => {
|
||||
expect(
|
||||
validateComfyNodeDef(
|
||||
buildNodeDef({
|
||||
...baseRemoteCombo,
|
||||
route: '/voices'
|
||||
})
|
||||
)
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an absolute http URL', () => {
|
||||
expect(
|
||||
validateComfyNodeDef(
|
||||
buildNodeDef({
|
||||
...baseRemoteCombo,
|
||||
route: 'http://api.example.com/voices'
|
||||
}),
|
||||
() => {}
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an absolute https URL', () => {
|
||||
expect(
|
||||
validateComfyNodeDef(
|
||||
buildNodeDef({
|
||||
...baseRemoteCombo,
|
||||
route: 'https://api.example.com/voices'
|
||||
}),
|
||||
() => {}
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a route with no leading slash', () => {
|
||||
expect(
|
||||
validateComfyNodeDef(
|
||||
buildNodeDef({
|
||||
...baseRemoteCombo,
|
||||
route: 'voices'
|
||||
}),
|
||||
() => {}
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user