Compare commits

...

4 Commits

Author SHA1 Message Date
bigcat88
71cf071361 dev: new flag useComfyApi for auth [ci skip] 2026-04-07 16:25:25 +03:00
bigcat88
8527e16f74 dev: /Combo-RemoteOptions [ci skip] 2026-04-07 16:25:24 +03:00
Dante
858946b0f5 fix: use getAssetFilename in ModelInfoPanel filename field (#10836)
# Summary

* Model Info sidebar panel displays `asset.name` (registry name) instead
of the actual filename from `user_metadata.filename`
* Other UI components (asset cards, widgets, missing model scan)
correctly use `getAssetFilename()` which prefers
`user_metadata.filename` over `asset.name`
* One-line template fix: `{{ asset.name }}` → `{{
getAssetFilename(asset) }}`
* Fixes #10598 

# Bug

`ModelInfoPanel.vue:35` used raw `asset.name` for the "File Name" field.
When `user_metadata.filename` differs from `asset.name` (e.g. registry
name vs actual path like `checkpoints/v1-5-pruned.safetensors`), users
see inconsistent filenames across the UI.

# AS-IS / TO-BE

<img width="800" height="600" alt="before-after-10598"
src="https://github.com/user-attachments/assets/15beb6c8-4bad-4ed2-9c85-6f8c7c0b6d3e"
/>


| | File Name field shows |
| :--- | :--- |
| **AS-IS** (bug) | `sdxl-lightning-4step` — raw `asset.name` (registry
display name) |
| **TO-BE** (fix) | `checkpoints/sdxl_lightning_4step.safetensors` —
`getAssetFilename(asset)` (actual file path) |

# Red-Green Verification

| Commit | CI Status | Purpose |
| :--- | :--- | :--- |
| `test: add failing test for ModelInfoPanel showing wrong filename` | 🔴
Red | Proves the test catches the bug |
| `fix: use getAssetFilename in ModelInfoPanel filename field` | 🟢 Green
| Proves the fix resolves the bug |

# Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] Unit test: `prefers user_metadata.filename over asset.name for
filename field`
- [ ] Manual: open Asset Browser → click a model → verify File Name in
Model Info panel matches the actual file path (requires
`--enable-assets`)
2026-04-07 21:26:47 +09:00
Christian Byrne
c5b183086d test: add unit tests for commandStore, extensionStore, widgetStore (STORE-04) (#10647)
## Summary

Adds 43 unit tests covering three priority Pinia stores that previously
had zero test coverage.

### commandStore (18 tests)
- `registerCommand` / `registerCommands` — single and batch
registration, duplicate warning
- `getCommand` — retrieval and undefined for missing
- `execute` — successful execution, metadata passing, error handler
delegation, missing command error
- `isRegistered` — presence check
- `loadExtensionCommands` — extension command registration with source,
skip when no commands
- `ComfyCommandImpl` — label/icon/tooltip resolution (string vs
function), menubarLabel defaulting

### extensionStore (16 tests)
- `registerExtension` — name validation, duplicate detection, disabled
extension warning
- `isExtensionEnabled` / `loadDisabledExtensionNames` — enable/disable
lifecycle
- Always-disabled hardcoded extensions (pysssss.Locking,
pysssss.SnapToGrid, pysssss.FaviconStatus, KJNodes.browserstatus)
- `enabledExtensions` — computed filter
- `isExtensionReadOnly` — hardcoded list check
- `inactiveDisabledExtensionNames` — ghost extension tracking
- Core extension capture and `hasThirdPartyExtensions` detection

### widgetStore (9 tests)
- Core widget availability via `ComfyWidgets`
- Custom widget registration and core/custom precedence
- `inputIsWidget` for both v1 array and v2 object InputSpec formats

## Part of
Test Coverage Q2 Overhaul — Phase 5 (Unit & Component Tests)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10647-test-add-unit-tests-for-commandStore-extensionStore-widgetStore-STORE-04-3316d73d365081e0b4f6ce913130e489)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Alexander Brown <448862+DrJKL@users.noreply.github.com>
2026-04-07 08:42:16 +00:00
17 changed files with 854 additions and 21 deletions

View File

@@ -61,6 +61,15 @@ describe('ModelInfoPanel', () => {
expect(wrapper.text()).toContain('my-model.safetensors')
})
it('prefers user_metadata.filename over asset.name for filename field', () => {
const asset = createMockAsset({
name: 'registry-display-name',
user_metadata: { filename: 'checkpoints/real-file.safetensors' }
})
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain('checkpoints/real-file.safetensors')
})
it('displays name from user_metadata when present', () => {
const asset = createMockAsset({
user_metadata: { name: 'My Custom Model' }

View File

@@ -32,7 +32,9 @@
</div>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.fileName')">
<span class="break-all text-muted-foreground">{{ asset.name }}</span>
<span class="break-all text-muted-foreground">{{
getAssetFilename(asset)
}}</span>
</ModelInfoField>
<ModelInfoField
v-if="sourceUrl"
@@ -232,6 +234,7 @@ import {
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FilterOption } from '@/platform/assets/types/filterTypes'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
import { AssetKindKey } from './form/dropdown/types'
import {
buildSearchText,
extractFilterValues,
getByPath,
mapToDropdownItem
} from '../utils/resolveItemSchema'
import { fetchRemoteRoute } from '../utils/resolveRemoteRoute'
const props = defineProps<{
modelValue?: string
widget: SimplifiedWidget<string | undefined>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { t } = useI18n()
const comboSpec = computed(() => {
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
return props.widget.spec
}
return undefined
})
const remoteConfig = computed(() => comboSpec.value?.remote!)
const itemSchema = computed(() => remoteConfig.value?.item_schema!)
const rawItems = ref<unknown[]>([])
const loading = ref(false)
async function fetchItems() {
loading.value = true
try {
const res = await fetchRemoteRoute(remoteConfig.value.route, {
params: remoteConfig.value.query_params,
timeout: remoteConfig.value.timeout ?? 30000,
useComfyApi: remoteConfig.value.use_comfy_api
})
const data = remoteConfig.value.response_key
? res.data[remoteConfig.value.response_key]
: res.data
rawItems.value = Array.isArray(data) ? data : []
} catch (err) {
console.error('RichComboWidget: fetch error', err)
} finally {
loading.value = false
}
}
onMounted(() => {
void fetchItems()
})
const assetKind = computed(() => {
const pt = itemSchema.value.preview_type ?? 'image'
return pt as 'image' | 'video' | 'audio'
})
provide(AssetKindKey, assetKind)
const items = computed<FormDropdownItem[]>(() =>
rawItems.value.map((raw) => mapToDropdownItem(raw, itemSchema.value))
)
const searchIndex = computed(() => {
const schema = itemSchema.value
const fields = schema.search_fields ?? [schema.label_field]
const index = new Map<string, string>()
for (const raw of rawItems.value) {
const id = String(getByPath(raw, schema.value_field) ?? '')
index.set(id, buildSearchText(raw, fields))
}
return index
})
const filterOptions = computed<FilterOption[]>(() => {
const schema = itemSchema.value
if (!schema.filter_field) return []
const values = extractFilterValues(rawItems.value, schema.filter_field)
return [
{ name: 'All', value: 'all' },
...values.map((v) => ({ name: v, value: v }))
]
})
const filterSelected = ref('all')
const layoutMode = ref<LayoutMode>('list')
const selectedSet = ref<Set<string>>(new Set())
const filteredItems = computed<FormDropdownItem[]>(() => {
const schema = itemSchema.value
if (filterSelected.value === 'all' || !schema.filter_field) {
return items.value
}
const filterField = schema.filter_field
return rawItems.value
.filter(
(raw) =>
String(getByPath(raw, filterField) ?? '') === filterSelected.value
)
.map((raw) => mapToDropdownItem(raw, schema))
})
async function searcher(
query: string,
searchItems: FormDropdownItem[],
_onCleanup: (cleanupFn: () => void) => void
): Promise<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)
})
}
watch(
[() => props.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() {
void fetchItems()
}
function handleSelection(selected: Set<string>) {
const id = selected.values().next().value
if (id) {
emit('update:modelValue', id)
}
}
</script>
<template>
<div class="flex w-full items-center gap-1">
<FormDropdown
v-model:selected="selectedSet"
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
:items="filteredItems"
:placeholder="loading ? 'Loading...' : t('widgets.uploadSelect.placeholder')"
:multiple="false"
:filter-options="[]"
:show-sort="false"
:show-layout-switcher="false"
:searcher="searcher"
class="flex-1"
@update:selected="handleSelection"
/>
<button
v-if="remoteConfig?.refresh_button !== false"
class="flex size-7 shrink-0 items-center justify-center rounded text-secondary hover:bg-component-node-widget-background-hovered"
title="Refresh"
@pointerdown.stop
@click.stop="handleRefresh"
>
<i
:class="[
'icon-[lucide--refresh-cw] size-3.5',
loading && 'animate-spin'
]"
/>
</button>
</div>
</template>

View File

@@ -1,6 +1,11 @@
<template>
<RichComboWidget
v-if="hasItemSchema"
v-model="modelValue"
:widget
/>
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-else-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
@@ -24,6 +29,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 +59,10 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
return undefined
})
const hasItemSchema = computed(
() => !!comboSpec.value?.remote?.item_schema
)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean

View File

@@ -34,6 +34,8 @@ interface Props {
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -61,6 +63,8 @@ const {
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -232,6 +236,8 @@ function handleSelection(item: FormDropdownItem, index: number) {
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-sort
:show-layout-switcher="showLayoutSwitcher"
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -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="showLayoutSwitcher"
: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)"
/>

View File

@@ -18,8 +18,13 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
defineProps<{
const {
showSort = true,
showLayoutSwitcher = true
} = defineProps<{
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -114,6 +119,7 @@ function toggleBaseModelSelection(item: FilterOption) {
/>
<Button
v-if="showSort"
ref="sortTriggerRef"
variant="textonly"
size="icon"
@@ -132,6 +138,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"
@@ -309,6 +316,7 @@ function toggleBaseModelSelection(item: FilterOption) {
</Popover>
<div
v-if="showLayoutSwitcher"
:class="
cn(
actionButtonStyle,

View File

@@ -12,6 +12,7 @@ interface Props {
previewUrl: string
name: string
label?: string
description?: string
layout?: LayoutMode
}
@@ -27,11 +28,31 @@ const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isAudio = computed(() => assetKind?.value === 'audio')
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlayingAudio = ref(false)
function handleClick() {
emit('click', props.index)
}
function toggleAudioPreview(event: Event) {
event.stopPropagation()
if (!audioRef.value) return
if (isPlayingAudio.value) {
audioRef.value.pause()
isPlayingAudio.value = false
} else {
void audioRef.value.play()
isPlayingAudio.value = true
}
}
function handleAudioEnded() {
isPlayingAudio.value = false
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
@@ -107,6 +128,25 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<div
v-else-if="previewUrl && isAudio"
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-tr from-violet-500 via-purple-500 to-fuchsia-400"
@click.stop="toggleAudioPreview"
>
<audio
ref="audioRef"
:src="previewUrl"
preload="none"
@ended="handleAudioEnded"
/>
<i
:class="
isPlayingAudio
? 'icon-[lucide--pause] size-5 text-white'
: 'icon-[lucide--play] size-5 text-white'
"
/>
</div>
<img
v-else-if="previewUrl"
:src="previewUrl"
@@ -144,6 +184,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 }}

View File

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

View File

@@ -214,7 +214,9 @@ const addComboWidget = (
}
)
if (inputSpec.remote) {
if (inputSpec.remote && !inputSpec.remote.item_schema) {
// Skip useRemoteWidget when item_schema is present —
// RichComboWidget handles its own data fetching and rendering.
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}

View File

@@ -2,10 +2,12 @@ import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
import {
getRemoteAuthHeaders,
resolveRoute
} from '../utils/resolveRemoteRoute'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -21,17 +23,6 @@ interface CacheEntry<T> {
failed?: boolean
}
async function getAuthHeaders() {
if (isCloud) {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
return {
...(authHeader && { headers: authHeader })
}
}
return {}
}
const dataCache = new Map<string, CacheEntry<unknown>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
@@ -73,9 +64,10 @@ const fetchData = async (
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const authHeaders = await getAuthHeaders()
const url = resolveRoute(route, config.use_comfy_api)
const authHeaders = await getRemoteAuthHeaders(config.use_comfy_api)
const res = await axios.get(route, {
const res = await axios.get(url, {
params: query_params,
signal: controller.signal,
timeout,

View File

@@ -0,0 +1,70 @@
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 full API response using response_key */
export function extractItems(
response: unknown,
responseKey?: string
): unknown[] {
const data = responseKey ? getByPath(response, responseKey) : response
return Array.isArray(data) ? data : []
}
/** 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()
}
/** Extract unique filter values from items */
export function extractFilterValues(
items: unknown[],
filterField: string
): string[] {
const values = new Set<string>()
for (const item of items) {
const value = getByPath(item, filterField)
if (value != null) values.add(String(value))
}
return Array.from(values).sort()
}

View File

@@ -0,0 +1,55 @@
import axios from 'axios'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useAuthStore } from '@/stores/authStore'
/**
* Resolve a RemoteOptions route to a full URL.
* - useComfyApi=true → prepend getComfyApiBaseUrl()
* - Otherwise → use as-is
*/
export function resolveRoute(
route: string,
useComfyApi?: boolean
): string {
if (useComfyApi) {
return getComfyApiBaseUrl() + route
}
return route
}
/**
* Get auth headers for a remote request.
* - useComfyApi=true → inject auth headers (comfy-api requires it)
* - Otherwise → no auth headers injected
*/
export async function getRemoteAuthHeaders(
useComfyApi?: boolean
): Promise<Record<string, any>> {
if (useComfyApi) {
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
useComfyApi?: boolean
} = {}
) {
const { useComfyApi, ...requestOptions } = options
const url = resolveRoute(route, useComfyApi)
const authHeaders = await getRemoteAuthHeaders(useComfyApi)
return axios.get(url, { ...requestOptions, ...authHeaders })
}

View File

@@ -5,6 +5,16 @@ import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
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(),
filter_field: z.string().optional()
})
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(),
@@ -13,7 +23,9 @@ const zRemoteWidgetConfig = z.object({
refresh_button: z.boolean().optional(),
control_after_refresh: z.enum(['first', 'last']).optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
max_retries: z.number().gte(0).optional(),
item_schema: zRemoteItemSchema.optional(),
use_comfy_api: z.boolean().optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
@@ -354,6 +366,7 @@ 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 ComboInputOptions = z.infer<typeof zComboInputOptions>

View File

@@ -0,0 +1,197 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCommandStore } from '@/stores/commandStore'
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync:
(fn: () => Promise<void>, errorHandler?: (e: unknown) => void) =>
async () => {
try {
await fn()
} catch (e) {
if (errorHandler) errorHandler(e)
else throw e
}
}
})
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybindingByCommandId: () => null
})
}))
describe('commandStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('registerCommand', () => {
it('registers a command by id', () => {
const store = useCommandStore()
store.registerCommand({
id: 'test.command',
function: vi.fn()
})
expect(store.isRegistered('test.command')).toBe(true)
})
it('warns on duplicate registration and overwrites with new function', async () => {
const store = useCommandStore()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const originalFn = vi.fn()
const replacementFn = vi.fn()
store.registerCommand({ id: 'dup', function: originalFn })
store.registerCommand({ id: 'dup', function: replacementFn })
expect(warnSpy).toHaveBeenCalledWith('Command dup already registered')
warnSpy.mockRestore()
await store.getCommand('dup')?.function()
expect(replacementFn).toHaveBeenCalled()
expect(originalFn).not.toHaveBeenCalled()
})
})
describe('getCommand', () => {
it('returns the registered command', () => {
const store = useCommandStore()
const fn = vi.fn()
store.registerCommand({ id: 'get.test', function: fn, label: 'Test' })
const cmd = store.getCommand('get.test')
expect(cmd).toBeDefined()
expect(cmd?.label).toBe('Test')
})
it('returns undefined for unregistered command', () => {
const store = useCommandStore()
expect(store.getCommand('nonexistent')).toBeUndefined()
})
})
describe('execute', () => {
it('executes a registered command', async () => {
const store = useCommandStore()
const fn = vi.fn()
store.registerCommand({ id: 'exec.test', function: fn })
await store.execute('exec.test')
expect(fn).toHaveBeenCalled()
})
it('throws for unregistered command', async () => {
const store = useCommandStore()
await expect(store.execute('missing')).rejects.toThrow(
'Command missing not found'
)
})
it('passes metadata to the command function', async () => {
const store = useCommandStore()
const fn = vi.fn()
store.registerCommand({ id: 'meta.test', function: fn })
await store.execute('meta.test', { metadata: { source: 'keyboard' } })
expect(fn).toHaveBeenCalledWith({ source: 'keyboard' })
})
it('calls errorHandler on failure', async () => {
const store = useCommandStore()
const error = new Error('fail')
store.registerCommand({
id: 'err.test',
function: () => {
throw error
}
})
const handler = vi.fn()
await store.execute('err.test', { errorHandler: handler })
expect(handler).toHaveBeenCalledWith(error)
})
})
describe('isRegistered', () => {
it('returns false for unregistered command', () => {
const store = useCommandStore()
expect(store.isRegistered('nope')).toBe(false)
})
})
describe('loadExtensionCommands', () => {
it('registers commands from an extension', () => {
const store = useCommandStore()
store.loadExtensionCommands({
name: 'test-ext',
commands: [
{ id: 'ext.cmd1', function: vi.fn(), label: 'Cmd 1' },
{ id: 'ext.cmd2', function: vi.fn(), label: 'Cmd 2' }
]
})
expect(store.isRegistered('ext.cmd1')).toBe(true)
expect(store.isRegistered('ext.cmd2')).toBe(true)
expect(store.getCommand('ext.cmd1')?.source).toBe('test-ext')
expect(store.getCommand('ext.cmd2')?.source).toBe('test-ext')
})
it('skips extensions without commands', () => {
const store = useCommandStore()
store.loadExtensionCommands({ name: 'no-commands' })
expect(store.commands).toHaveLength(0)
})
})
describe('getCommand resolves dynamic properties', () => {
it('resolves label as function', () => {
const store = useCommandStore()
store.registerCommand({
id: 'label.fn',
function: vi.fn(),
label: () => 'Dynamic'
})
expect(store.getCommand('label.fn')?.label).toBe('Dynamic')
})
it('resolves tooltip as function', () => {
const store = useCommandStore()
store.registerCommand({
id: 'tip.fn',
function: vi.fn(),
tooltip: () => 'Dynamic tip'
})
expect(store.getCommand('tip.fn')?.tooltip).toBe('Dynamic tip')
})
it('uses explicit menubarLabel over label', () => {
const store = useCommandStore()
store.registerCommand({
id: 'mbl.explicit',
function: vi.fn(),
label: 'Label',
menubarLabel: 'Menu Label'
})
expect(store.getCommand('mbl.explicit')?.menubarLabel).toBe('Menu Label')
})
it('falls back menubarLabel to label', () => {
const store = useCommandStore()
store.registerCommand({
id: 'mbl.default',
function: vi.fn(),
label: 'My Label'
})
expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label')
})
})
describe('formatKeySequence', () => {
it('returns empty string when command has no keybinding', () => {
const store = useCommandStore()
store.registerCommand({ id: 'no.kb', function: vi.fn() })
const cmd = store.getCommand('no.kb')!
expect(store.formatKeySequence(cmd)).toBe('')
})
})
})

View File

@@ -0,0 +1,152 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useExtensionStore } from '@/stores/extensionStore'
describe('extensionStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('registerExtension', () => {
it('registers an extension by name', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'test.ext' })
expect(store.isExtensionInstalled('test.ext')).toBe(true)
})
it('throws for extension without name', () => {
const store = useExtensionStore()
expect(() => store.registerExtension({ name: '' })).toThrow(
"Extensions must have a 'name' property."
)
})
it('throws for duplicate registration', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'dup' })
expect(() => store.registerExtension({ name: 'dup' })).toThrow(
"Extension named 'dup' already registered."
)
})
it('warns when registering a disabled extension but still installs it', () => {
const store = useExtensionStore()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
store.loadDisabledExtensionNames(['disabled.ext'])
store.registerExtension({ name: 'disabled.ext' })
expect(warnSpy).toHaveBeenCalledWith(
'Extension disabled.ext is disabled.'
)
expect(store.isExtensionInstalled('disabled.ext')).toBe(true)
expect(store.isExtensionEnabled('disabled.ext')).toBe(false)
} finally {
warnSpy.mockRestore()
}
})
})
describe('isExtensionInstalled', () => {
it('returns false for uninstalled extension', () => {
const store = useExtensionStore()
expect(store.isExtensionInstalled('missing')).toBe(false)
})
})
describe('isExtensionEnabled / loadDisabledExtensionNames', () => {
it('all extensions are enabled by default', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'fresh' })
expect(store.isExtensionEnabled('fresh')).toBe(true)
})
it('disables extensions from provided list', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames(['off.ext'])
store.registerExtension({ name: 'off.ext' })
expect(store.isExtensionEnabled('off.ext')).toBe(false)
})
it('always disables hardcoded extensions', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames([])
store.registerExtension({ name: 'pysssss.Locking' })
store.registerExtension({ name: 'regular.ext' })
expect(store.isExtensionEnabled('pysssss.Locking')).toBe(false)
expect(store.isExtensionEnabled('pysssss.SnapToGrid')).toBe(false)
expect(store.isExtensionEnabled('pysssss.FaviconStatus')).toBe(false)
expect(store.isExtensionEnabled('KJNodes.browserstatus')).toBe(false)
expect(store.isExtensionEnabled('regular.ext')).toBe(true)
})
})
describe('enabledExtensions', () => {
it('filters out disabled extensions', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames(['ext.off'])
store.registerExtension({ name: 'ext.on' })
store.registerExtension({ name: 'ext.off' })
const enabled = store.enabledExtensions
expect(enabled).toHaveLength(1)
expect(enabled[0].name).toBe('ext.on')
})
})
describe('isExtensionReadOnly', () => {
it('returns true for always-disabled extensions', () => {
const store = useExtensionStore()
expect(store.isExtensionReadOnly('pysssss.Locking')).toBe(true)
})
it('returns false for normal extensions', () => {
const store = useExtensionStore()
expect(store.isExtensionReadOnly('some.custom.ext')).toBe(false)
})
})
describe('inactiveDisabledExtensionNames', () => {
it('returns disabled names not currently installed', () => {
const store = useExtensionStore()
store.loadDisabledExtensionNames(['ghost.ext', 'installed.ext'])
store.registerExtension({ name: 'installed.ext' })
expect(store.inactiveDisabledExtensionNames).toContain('ghost.ext')
expect(store.inactiveDisabledExtensionNames).not.toContain(
'installed.ext'
)
})
})
describe('core extensions', () => {
it('captures current extensions as core', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'core.a' })
store.registerExtension({ name: 'core.b' })
store.captureCoreExtensions()
expect(store.isCoreExtension('core.a')).toBe(true)
expect(store.isCoreExtension('core.b')).toBe(true)
})
it('identifies third-party extensions registered after capture', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'core.x' })
store.captureCoreExtensions()
expect(store.hasThirdPartyExtensions).toBe(false)
store.registerExtension({ name: 'third.party' })
expect(store.hasThirdPartyExtensions).toBe(true)
})
it('returns false for isCoreExtension before capture', () => {
const store = useExtensionStore()
store.registerExtension({ name: 'ext.pre' })
expect(store.isCoreExtension('ext.pre')).toBe(false)
})
})
})

View File

@@ -0,0 +1,76 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ComfyWidgets } from '@/scripts/widgets'
import { useWidgetStore } from '@/stores/widgetStore'
vi.mock('@/scripts/widgets', () => ({
ComfyWidgets: {
INT: vi.fn(),
FLOAT: vi.fn(),
STRING: vi.fn(),
BOOLEAN: vi.fn(),
COMBO: vi.fn()
}
}))
vi.mock('@/schemas/nodeDefSchema', () => ({
getInputSpecType: (spec: unknown[]) => spec[0]
}))
describe('widgetStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('widgets getter', () => {
it('includes custom widgets after registration', () => {
const store = useWidgetStore()
const customFn = vi.fn()
store.registerCustomWidgets({ CUSTOM_TYPE: customFn })
expect(store.widgets.get('CUSTOM_TYPE')).toBe(customFn)
})
it('core widgets take precedence over custom widgets with same key', () => {
const store = useWidgetStore()
const override = vi.fn()
store.registerCustomWidgets({ INT: override })
expect(store.widgets.get('INT')).toBe(ComfyWidgets.INT)
})
})
describe('inputIsWidget', () => {
it('returns true for known widget type (v1 spec)', () => {
const store = useWidgetStore()
expect(store.inputIsWidget(['INT', {}] as const)).toBe(true)
})
it('returns false for unknown type (v1 spec)', () => {
const store = useWidgetStore()
expect(store.inputIsWidget(['UNKNOWN_TYPE', {}] as const)).toBe(false)
})
it('returns true for v2 spec with known type', () => {
const store = useWidgetStore()
expect(store.inputIsWidget({ type: 'STRING', name: 'test_input' })).toBe(
true
)
})
it('returns false for v2 spec with unknown type', () => {
const store = useWidgetStore()
expect(store.inputIsWidget({ type: 'LATENT', name: 'test_input' })).toBe(
false
)
})
it('returns true for custom registered type', () => {
const store = useWidgetStore()
store.registerCustomWidgets({ MY_WIDGET: vi.fn() })
expect(
store.inputIsWidget({ type: 'MY_WIDGET', name: 'test_input' })
).toBe(true)
})
})
})