feat(widgets): add Description tab to LoraInfoWidget with dual-mode rendering support

- Add Notes/Description tab switching with tab state persistence in widget value
- Lazy-load model description and version description from /lm/loras/metadata
- Render CivitAI HTML descriptions inline via v-html
- Auto-fetch description when LoRA selection changes while on Description tab
- Fix Vue mode height containment via contain:layout size (lm-vue-node class)
- Fix scroll wheel isolation: widget scroll vs canvas zoom in both render modes
- Add docs/comfyui-dual-mode-widgets.md with widget rendering patterns
This commit is contained in:
Will Miao
2026-07-17 15:04:47 +08:00
parent 7f51812c1e
commit 6f71335be4
7 changed files with 1445 additions and 160 deletions

View File

@@ -102,6 +102,7 @@ npm run test:coverage # Generate coverage report
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)` - ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
- Event handlers via `addEventListener` or widget callbacks - Event handlers via `addEventListener` or widget callbacks
- Shared utilities: `web/comfyui/utils.js` - Shared utilities: `web/comfyui/utils.js`
- Dual-mode rendering patterns (canvas vs Vue): see `docs/comfyui-dual-mode-widgets.md`
### Vue Composables Pattern ### Vue Composables Pattern

View File

@@ -0,0 +1,65 @@
# ComfyUI Dual-Mode Widget Rendering
ComfyUI custom node widgets render in one of two modes. Patterns that work in one often fail silently in the other. Test both.
## Mode Detection
```js
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
```
In Vue SFCs, `window.LiteGraph` is unavailable — pass as a prop from `main.ts`.
## Canvas Mode Layout
Uses `computeLayoutSize()` + `distributeSpace()` to allocate widget height within the node. Widgets with `computeLayoutSize` participate in space distribution; those with `computeSize` have fixed height.
- `getMinHeight()` in `addDOMWidget` options → minimum widget height
- `widget.computeLayoutSize()``{ minHeight, minWidth, maxHeight? }`
- Avoid `getMaxHeight()` unless the widget genuinely needs a fixed cap (prevents user resize)
## Vue Mode Layout
Uses CSS Grid (`grid-template-rows`) + `ResizeObserver`. The ResizeObserver watches the widget's DOM and feeds back into grid row sizing. This creates a feedback loop: content grows → row resizes → more space for content → content reflows/grows → row resizes again.
### Height Containment
The fix: `contain: layout size` on the widget root. This tells the browser the element's intrinsic size is CSS-determined, not driven by descendant content. The ResizeObserver sees a stable size and the loop is broken.
```css
.widget-root.lm-vue-node {
height: 100%;
min-height: var(--comfy-widget-min-height, 200px);
contain: layout size;
}
```
Existing examples: `.lm-loras-container.lm-vue-node` and `.comfy-tags-container.lm-vue-node` in `web/comfyui/lm_styles.css`.
**Do NOT** fix height issues with `maxHeight`, `getMaxHeight()`, or inline `max-height` — these prevent the user from resizing the node.
## Scroll Wheel Isolation
Both modes need to distinguish "user wants to scroll widget content" from "user wants to zoom canvas".
**Canvas mode:** Add `@wheel` on widget root. Check `event.target.closest(selector)` for scrollable sub-areas. If scrollable → `event.stopPropagation()`. Otherwise → `app.canvas.processMouseWheel(event)`.
**Vue mode:** Add CSS class `lm-wheel-scrollable` to scrollable elements. The global capture-phase hook in `web/comfyui/utils.js` (`enableListWheelScroll`) detects wheel events on marked elements and manually scrolls them via `element.scrollTop`, consuming the event before canvas zoom sees it.
## DOM Structure
`main.ts` creates an outer `<div>` container, then `vueApp.mount(container)`. The Vue app renders its own root element inside.
- `container.id` / `container.style.*` → outer element
- Vue scoped `<style>``[data-v-hash]` applies only to Vue root
Classes needed by scoped Vue CSS must go on the Vue root element. Pass data as props and bind with `:class` rather than manipulating the DOM from `main.ts`.
## Serialization
For stateful widgets that need workflow persistence:
- `serialize: true` in `addDOMWidget` options
- `serializeValue()` → state snapshot (called on workflow save)
- `onSetValue(v)` → restore state (called on workflow load)
- Always handle missing keys in restored value for backward compatibility with old workflows

View File

@@ -1,6 +1,37 @@
<template> <template>
<div class="lora-info-widget"> <div class="lora-info-widget" :class="{ 'lm-vue-node': isVueMode }" @wheel="onWheel">
<template v-if="loraName"> <template v-if="loraName">
<!-- Tab bar -->
<div class="lora-info-tabs">
<label
class="lora-info-tab"
:class="{ active: activeTab === 'notes' }"
>
<input
type="radio"
v-model="activeTab"
value="notes"
class="lora-info-tab-input"
/>
<span class="lora-info-tab-label">Notes</span>
</label>
<label
class="lora-info-tab"
:class="{ active: activeTab === 'description' }"
>
<input
type="radio"
v-model="activeTab"
value="description"
class="lora-info-tab-input"
@change="onDescriptionTabActivated"
/>
<span class="lora-info-tab-label">Description</span>
</label>
</div>
<!-- Notes tab content -->
<div v-show="activeTab === 'notes'" class="tab-content notes-tab">
<div class="info-field"> <div class="info-field">
<label class="info-label">Filename</label> <label class="info-label">Filename</label>
<div class="lora-filename">{{ loraName }}</div> <div class="lora-filename">{{ loraName }}</div>
@@ -9,7 +40,7 @@
<label class="info-label">Notes</label> <label class="info-label">Notes</label>
<textarea <textarea
v-model="notes" v-model="notes"
class="lora-notes" class="lora-notes lm-wheel-scrollable"
placeholder="Add notes about this LoRA..." placeholder="Add notes about this LoRA..."
:disabled="saving" :disabled="saving"
></textarea> ></textarea>
@@ -21,20 +52,60 @@
> >
{{ saving ? 'Saving...' : 'Save' }} {{ saving ? 'Saving...' : 'Save' }}
</button> </button>
</div>
<!-- Description tab content -->
<div v-show="activeTab === 'description'" class="tab-content description-tab lm-wheel-scrollable">
<!-- Loading state -->
<div v-if="descriptionLoading" class="description-state">
<i class="fas fa-spinner fa-spin"></i>
<span>Loading description...</span>
</div>
<!-- Error state -->
<div v-else-if="descriptionError" class="description-state error">
<span>Failed to load description</span>
</div>
<!-- Empty state (loaded but no content) -->
<div v-else-if="!hasDescription" class="description-state placeholder">
<span>No description available</span>
</div>
<!-- Description content -->
<div v-else class="description-content">
<div v-if="versionDescription" class="description-section">
<label class="info-label">About this version</label>
<div class="description-text" v-html="versionDescription"></div>
</div>
<div v-if="modelDescription" class="description-section">
<label class="info-label">Model Description</label>
<div class="description-text" v-html="modelDescription"></div>
</div>
</div>
</div>
</template> </template>
<div v-else class="placeholder">No LoRA selected</div> <div v-else class="placeholder">No LoRA selected</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref, computed, watch } from 'vue'
interface LoraInfoWidget { interface LoraInfoWidget {
serializeValue?: () => Promise<unknown> serializeValue?: () => Promise<unknown>
value?: unknown value?: unknown
onSetValue?: (v: unknown) => void onSetValue?: (v: unknown) => void
callback?: unknown callback?: unknown
_setLoraInfo?: (data: { name: string; notes: string; filePath: string }) => void _setLoraInfo?: (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => void
__pendingLoraInfo?: { name: string; notes: string; filePath: string; activeTab?: string } | null
}
interface LoraInfoWidgetValue {
name?: string
notes?: string
filePath?: string
activeTab?: string
} }
const props = defineProps<{ const props = defineProps<{
@@ -42,6 +113,7 @@ const props = defineProps<{
node: { id: number } node: { id: number }
api: { fetchApi: (url: string, options?: RequestInit) => Promise<Response> } api: { fetchApi: (url: string, options?: RequestInit) => Promise<Response> }
app: { extensionManager: { toast: { add: (opts: Record<string, unknown>) => void } } } app: { extensionManager: { toast: { add: (opts: Record<string, unknown>) => void } } }
isVueMode?: boolean
}>() }>()
const loraName = ref<string>('') const loraName = ref<string>('')
@@ -49,6 +121,69 @@ const notes = ref<string>('')
const originalNotes = ref<string>('') const originalNotes = ref<string>('')
const filePath = ref<string>('') const filePath = ref<string>('')
const saving = ref<boolean>(false) const saving = ref<boolean>(false)
const activeTab = ref<string>('notes')
// Description tab state
const versionDescription = ref<string>('')
const modelDescription = ref<string>('')
const descriptionLoading = ref<boolean>(false)
const descriptionError = ref<boolean>(false)
const descriptionLoaded = ref<boolean>(false)
const hasDescription = computed(() =>
!!(versionDescription.value || modelDescription.value)
)
// Reset and auto-fetch description state when the LoRA selection changes
watch(filePath, (newPath) => {
descriptionLoaded.value = false
descriptionError.value = false
versionDescription.value = ''
modelDescription.value = ''
if (newPath && activeTab.value === 'description') {
fetchDescription()
}
})
function onDescriptionTabActivated() {
if (!descriptionLoaded.value && filePath.value) {
fetchDescription()
}
}
async function fetchDescription() {
if (descriptionLoading.value || !filePath.value) return
descriptionLoading.value = true
descriptionError.value = false
try {
const response = await props.api.fetchApi(
`/lm/loras/metadata?file_path=${encodeURIComponent(filePath.value)}`,
{ method: 'GET' }
)
if (!response.ok) {
throw new Error(`Failed to fetch metadata: ${response.statusText}`)
}
const data = await response.json()
if (data.success && data.metadata) {
versionDescription.value = data.metadata.description || ''
modelDescription.value = data.metadata.model?.description || ''
descriptionLoaded.value = true
} else {
// Successful response but no metadata — treat as empty, not error
descriptionLoaded.value = true
}
} catch (e) {
console.error('[LoraInfoWidget] Failed to fetch description:', e)
descriptionError.value = true
// Don't set descriptionLoaded — allow retry on next tab switch
} finally {
descriptionLoading.value = false
}
}
async function saveNotes() { async function saveNotes() {
if (notes.value === originalNotes.value || saving.value) return if (notes.value === originalNotes.value || saving.value) return
@@ -91,47 +226,99 @@ async function saveNotes() {
} }
} }
function onWheel(event: WheelEvent) {
const target = event.target as HTMLElement | null
if (!target) return
const comfyApp = (window as unknown as { app?: { canvas?: { processMouseWheel?: (e: WheelEvent) => void } } }).app
if (!comfyApp?.canvas?.processMouseWheel) return
// Always pass pinch-to-zoom to canvas
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
comfyApp.canvas.processMouseWheel(event)
return
}
// Horizontal scroll: pass to canvas
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
event.preventDefault()
event.stopPropagation()
comfyApp.canvas.processMouseWheel(event)
return
}
// Check if the target is inside a scrollable area (notes textarea or description tab)
const scrollableEl = target.closest('.lora-notes, .description-tab') as HTMLElement | null
if (scrollableEl) {
const canScrollY = scrollableEl.scrollHeight > scrollableEl.clientHeight
if (canScrollY) {
// Let native scroll handle it, but stop propagation to prevent canvas zoom
event.stopPropagation()
return
}
}
// Forward to canvas for zoom
event.preventDefault()
event.stopPropagation()
comfyApp.canvas.processMouseWheel(event)
}
onMounted(() => { onMounted(() => {
// Display-only widget - return null on serialization to avoid saving to workflow // Persist tab state along with other widget data
props.widget.serializeValue = async () => null props.widget.serializeValue = async (): Promise<LoraInfoWidgetValue> => ({
name: loraName.value,
notes: notes.value,
filePath: filePath.value,
activeTab: activeTab.value,
})
// Handle external value updates (e.g., loading workflow, paste) // Handle external value updates (e.g., loading workflow, paste)
props.widget.onSetValue = (v: unknown) => { props.widget.onSetValue = (v: unknown) => {
if (v && typeof v === 'object') { if (v && typeof v === 'object') {
const data = v as { name?: string; notes?: string; filePath?: string } const data = v as LoraInfoWidgetValue
if (data.name !== undefined) loraName.value = data.name if (data.name !== undefined) loraName.value = data.name
if (data.notes !== undefined) { if (data.notes !== undefined) {
notes.value = data.notes notes.value = data.notes
originalNotes.value = data.notes originalNotes.value = data.notes
} }
if (data.filePath !== undefined) filePath.value = data.filePath if (data.filePath !== undefined) filePath.value = data.filePath
if (data.activeTab !== undefined) activeTab.value = data.activeTab
} }
} }
// Restore from saved value if exists (for workflow loading) // Restore from saved value if exists (for workflow loading)
if (props.widget.value && typeof props.widget.value === 'object') { if (props.widget.value && typeof props.widget.value === 'object') {
const data = props.widget.value as { name?: string; notes?: string; filePath?: string } const data = props.widget.value as LoraInfoWidgetValue
if (data.name !== undefined) loraName.value = data.name if (data.name !== undefined) loraName.value = data.name
if (data.notes !== undefined) { if (data.notes !== undefined) {
notes.value = data.notes notes.value = data.notes
originalNotes.value = data.notes originalNotes.value = data.notes
} }
if (data.filePath !== undefined) filePath.value = data.filePath if (data.filePath !== undefined) filePath.value = data.filePath
if (data.activeTab !== undefined) activeTab.value = data.activeTab
} }
// Expose setLoraInfo on the widget object for external callers (e.g., lora_info.js). // Expose setLoraInfo on the widget object for external callers (e.g., lora_info.js).
// Accepts null to clear the display (when selection is deselected). // Accepts null to clear the display (when selection is deselected).
props.widget._setLoraInfo = (data: { name: string; notes: string; filePath: string } | null) => { props.widget._setLoraInfo = (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => {
if (data) { if (data) {
loraName.value = data.name loraName.value = data.name
notes.value = data.notes notes.value = data.notes
originalNotes.value = data.notes originalNotes.value = data.notes
filePath.value = data.filePath filePath.value = data.filePath
// Preserve existing activeTab unless explicitly provided
if (data.activeTab !== undefined) {
activeTab.value = data.activeTab
}
} else { } else {
loraName.value = '' loraName.value = ''
notes.value = '' notes.value = ''
originalNotes.value = '' originalNotes.value = ''
filePath.value = '' filePath.value = ''
// Do NOT reset activeTab on deselection — user's tab preference persists
} }
} }
@@ -155,6 +342,86 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
} }
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
contain:layout size tells the browser the element's intrinsic size is
determined solely by CSS — not by descendant content. This breaks the
feedback loop where content grows → ResizeObserver resizes → content
reflows → repeat. Same technique used by tags_widget.js + lm_styles.css. */
.lora-info-widget.lm-vue-node {
contain: layout size;
}
/* ── Tab bar ── */
.lora-info-tabs {
display: flex;
gap: 0;
margin-bottom: 10px;
border-bottom: 1px solid var(--border-color, #444);
flex-shrink: 0;
}
.lora-info-tab {
flex: 1;
text-align: center;
cursor: pointer;
padding: 6px 0;
position: relative;
}
.lora-info-tab-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.lora-info-tab-label {
font-size: 12px;
font-weight: 500;
color: var(--fg-color, #fff);
opacity: 0.5;
transition: opacity 0.15s;
}
.lora-info-tab:hover .lora-info-tab-label {
opacity: 0.75;
}
.lora-info-tab.active .lora-info-tab-label {
opacity: 1;
}
.lora-info-tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 25%;
right: 25%;
height: 2px;
background: rgba(66, 153, 225, 0.8);
border-radius: 1px;
}
/* ── Tab content ── */
.tab-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
.notes-tab {
display: flex;
flex-direction: column;
}
.description-tab {
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
}
/* ── Info fields (shared) ── */
.info-field { .info-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -235,6 +502,87 @@ onMounted(() => {
border-color: rgba(226, 232, 240, 0.1); border-color: rgba(226, 232, 240, 0.1);
} }
/* ── Description states ── */
.description-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 16px;
color: var(--fg-color, #fff);
opacity: 0.5;
font-size: 12px;
min-height: 0;
flex-shrink: 0;
}
.description-state.error {
opacity: 0.7;
color: #f87171;
}
/* ── Description content ── */
.description-content {
min-height: 0;
}
.description-section {
margin-bottom: 14px;
}
.description-section:last-child {
margin-bottom: 0;
}
.description-text {
padding: 8px 0;
font-size: 12px;
line-height: 1.5;
color: var(--fg-color, #fff);
opacity: 0.85;
word-break: break-word;
}
.description-text :deep(p) {
margin: 0 0 8px 0;
}
.description-text :deep(p:last-child) {
margin-bottom: 0;
}
.description-text :deep(a) {
color: rgba(66, 153, 225, 0.9);
}
.description-text :deep(ul),
.description-text :deep(ol) {
padding-left: 20px;
margin: 4px 0;
}
.description-text :deep(h1),
.description-text :deep(h2),
.description-text :deep(h3) {
font-size: 13px;
margin: 10px 0 4px 0;
font-weight: 600;
opacity: 0.95;
}
.description-text :deep(code) {
background: rgba(255, 255, 255, 0.08);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
.description-text :deep(img) {
max-width: 100%;
border-radius: 4px;
}
/* ── Placeholder (shared) ── */
.placeholder { .placeholder {
font-style: italic; font-style: italic;
color: rgba(226, 232, 240, 0.5); color: rgba(226, 232, 240, 0.5);
@@ -242,4 +590,14 @@ onMounted(() => {
padding: 16px 0; padding: 16px 0;
font-size: 12px; font-size: 12px;
} }
/* ── Spinner (Font Awesome) ── */
.fa-spinner {
animation: fa-spin 1s linear infinite;
}
@keyframes fa-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style> </style>

View File

@@ -656,7 +656,7 @@ function createLoraInfoWidget(node: any) {
forwardMiddleMouseToCanvas(container) forwardMiddleMouseToCanvas(container)
let internalValue: { name?: string; notes?: string; filePath?: string } | undefined let internalValue: { name?: string; notes?: string; filePath?: string; activeTab?: string } | undefined
const widget = node.addDOMWidget( const widget = node.addDOMWidget(
'lora_info_display', 'lora_info_display',
@@ -666,13 +666,13 @@ function createLoraInfoWidget(node: any) {
getValue() { getValue() {
return internalValue return internalValue
}, },
setValue(v: { name?: string; notes?: string; filePath?: string }) { setValue(v: { name?: string; notes?: string; filePath?: string; activeTab?: string }) {
internalValue = v internalValue = v
if (typeof widget.onSetValue === 'function') { if (typeof widget.onSetValue === 'function') {
widget.onSetValue(v) widget.onSetValue(v)
} }
}, },
serialize: false, // Display-only widget serialize: true,
getMinHeight() { getMinHeight() {
return LORA_INFO_WIDGET_MIN_HEIGHT return LORA_INFO_WIDGET_MIN_HEIGHT
} }
@@ -684,6 +684,7 @@ function createLoraInfoWidget(node: any) {
node, node,
api, api,
app, app,
isVueMode: typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode,
}) })
vueApp.use(PrimeVue, { vueApp.use(PrimeVue, {

View File

@@ -0,0 +1,417 @@
/**
* Tests for LoraInfoWidget — tab switching, lazy description loading,
* state serialization roundtrip, and activeTab persistence.
*/
import { nextTick } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import LoraInfoWidget from '@/components/LoraInfoWidget.vue'
import { setupFetchMock, resetFetchMock } from '../setup'
// ── Helpers ──
function createMockFetchApi(overrides: {
response?: unknown
ok?: boolean
error?: string
} = {}) {
const { response = { success: true, metadata: {} }, ok = true } = overrides
return vi.fn().mockResolvedValue({
ok,
json: () => Promise.resolve(response),
})
}
function createMockToast() {
return { add: vi.fn() }
}
function createMockWidget(value?: unknown) {
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
const widget = {
serializeValue: (async () => null) as () => Promise<unknown>,
value: (value ?? undefined) as unknown,
onSetValue: undefined as unknown as ((v: unknown) => void),
_setLoraInfo: undefined as unknown as (data: Record<string, unknown> | null) => void,
__pendingLoraInfo: undefined as unknown as PendingInfo | undefined,
}
return widget
}
interface MountOptions {
initialValue?: Record<string, unknown>
}
type TestWidget = ReturnType<typeof createMockWidget>
function mountWidget(options: MountOptions = {}) {
const fetchApi = createMockFetchApi()
const widget = createMockWidget(options.initialValue)
const node = { id: 1 }
const app = { extensionManager: { toast: createMockToast() } }
const wrapper = shallowMount(LoraInfoWidget, {
props: { widget, node, api: { fetchApi }, app },
})
return { wrapper, widget: widget as TestWidget, fetchApi, app }
}
// ── Tests ──
describe('LoraInfoWidget', () => {
beforeEach(() => {
setupFetchMock()
})
afterEach(() => {
resetFetchMock()
})
describe('initial state', () => {
it('shows placeholder when no LoRA is selected', () => {
const { wrapper } = mountWidget()
expect(wrapper.text()).toContain('No LoRA selected')
})
it('shows Notes tab by default when LoRA is set', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
expect(wrapper.text()).toContain('test.safetensors')
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
expect(wrapper.find('.description-tab').isVisible()).toBe(false)
})
})
describe('tab switching', () => {
it('switches to Description tab and back to Notes', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const tabs = wrapper.findAll('.lora-info-tab')
// Click Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
expect(tabs[1].classes()).toContain('active')
expect(wrapper.text()).toContain('No description available')
// Switch back to Notes
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
await notesTab.setValue('notes')
await nextTick()
expect(tabs[0].classes()).toContain('active')
expect(wrapper.text()).toContain('test.safetensors')
})
})
describe('description lazy loading', () => {
it('fetches metadata when Description tab is activated', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '<p>Version desc</p>',
model: { description: '<p>Model desc</p>' },
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
// Switch to Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick() // flush async fetch
expect(fetchApi).toHaveBeenCalledWith(
expect.stringContaining('/lm/loras/metadata'),
expect.objectContaining({ method: 'GET' })
)
expect(wrapper.html()).toContain('Version desc')
expect(wrapper.html()).toContain('Model desc')
})
it('shows loading state while fetching', async () => {
// Use a never-resolving promise to simulate loading
const fetchApi = vi.fn().mockReturnValue(new Promise(() => {}))
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
expect(wrapper.text()).toContain('Loading description')
})
it('shows error state when fetch fails', async () => {
const fetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(wrapper.text()).toContain('Failed to load description')
})
it('shows empty state when metadata has no descriptions', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '',
model: {},
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(wrapper.text()).toContain('No description available')
})
it('caches description and does not re-fetch on second activation', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '<p>Version desc</p>',
model: { description: '<p>Model desc</p>' },
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
// First activation
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(fetchApi).toHaveBeenCalledTimes(1)
// Switch away and back
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
await notesTab.setValue('notes')
await nextTick()
await descriptionTab.setValue('description')
await nextTick()
// Should NOT have called fetch again
expect(fetchApi).toHaveBeenCalledTimes(1)
})
it('re-fetches when LoRA selection changes', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '<p>Version desc</p>',
model: { description: '<p>Model desc</p>' },
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'first.safetensors', notes: '', filePath: '/path/first.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(fetchApi).toHaveBeenCalledTimes(1)
// Select a different LoRA — resets description state
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
await nextTick()
// Should show loading again (not cached)
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(fetchApi).toHaveBeenCalledTimes(2)
})
})
describe('serialization roundtrip', () => {
it('serializeValue includes activeTab', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: 'my notes', filePath: '/path/test.safetensors' })
await nextTick()
// Switch to Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
const serialized = await widget.serializeValue!()
expect(serialized).toMatchObject({
name: 'test.safetensors',
notes: 'my notes',
filePath: '/path/test.safetensors',
activeTab: 'description',
})
})
it('onSetValue restores activeTab from workflow value', async () => {
const { wrapper } = mountWidget({
initialValue: {
name: 'saved.safetensors',
notes: 'saved notes',
filePath: '/path/saved.safetensors',
activeTab: 'description',
},
})
await nextTick()
// Description tab should be visible (activeTab restored to 'description')
expect(wrapper.find('.description-tab').isVisible()).toBe(true)
expect(wrapper.text()).toContain('saved.safetensors')
})
it('defaults to notes tab when activeTab is missing in saved value', async () => {
const { wrapper } = mountWidget({
initialValue: {
name: 'legacy.safetensors',
notes: 'legacy notes',
filePath: '/path/legacy.safetensors',
// No activeTab — legacy workflow
},
})
await nextTick()
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
})
})
describe('_setLoraInfo race condition guard', () => {
it('consumes __pendingLoraInfo pushed before mount', async () => {
const widget = createMockWidget()
widget.__pendingLoraInfo = {
name: 'pending.safetensors',
notes: 'pending notes',
filePath: '/path/pending.safetensors',
}
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi: createMockFetchApi() },
app: { extensionManager: { toast: createMockToast() } },
},
})
await nextTick()
expect(widget.__pendingLoraInfo).toBeUndefined()
expect(wrapper.text()).toContain('pending.safetensors')
})
it('preserves activeTab when _setLoraInfo called with null (deselection)', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
// Switch to Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
// Deselect — template shows placeholder (no tab bar rendered)
widget._setLoraInfo!(null)
await nextTick()
// Placeholder shown
expect(wrapper.text()).toContain('No LoRA selected')
// Re-select — activeTab should still be 'description'
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
await nextTick()
const tabs = wrapper.findAll('.lora-info-tab')
expect(tabs[1].classes()).toContain('active')
})
})
})

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long