Merge remote-tracking branch 'origin/main' into bl-more-slots

This commit is contained in:
Benjamin Lu
2025-09-20 18:24:02 -07:00
68 changed files with 2185 additions and 783 deletions

1
.gitignore vendored
View File

@@ -44,6 +44,7 @@ components.d.ts
tests-ui/data/*
tests-ui/ComfyUI_examples
tests-ui/workflows/examples
coverage/
# Browser tests
/test-results/

View File

@@ -83,6 +83,13 @@ export default defineConfig([
'vue/no-restricted-class': ['error', '/^dark:/'],
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
/* Toggle on to do additional until we can clean up existing violations.
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-properties': 'error',
'vue/no-unused-refs': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/no-useless-v-bind': 'error',
// */
'vue/one-component-per-file': 'off', // TODO: fix
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components

View File

@@ -27,6 +27,8 @@
"preview": "nx preview",
"lint": "eslint src --cache",
"lint:fix": "eslint src --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"knip": "knip --cache",
@@ -94,6 +96,7 @@
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^3.2.4",
"vue-component-type-helpers": "^3.0.7",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.7",
"zip-dir": "^2.0.0",

3
pnpm-lock.yaml generated
View File

@@ -339,6 +339,9 @@ importers:
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
vue-component-type-helpers:
specifier: ^3.0.7
version: 3.0.7
vue-eslint-parser:
specifier: ^10.2.0
version: 10.2.0(eslint@9.35.0(jiti@2.4.2))

98
src/base/common/async.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* Cross-browser async utilities for scheduling tasks during browser idle time
* with proper fallbacks for browsers that don't support requestIdleCallback.
*
* Implementation based on:
* https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts
*/
interface IdleDeadline {
didTimeout: boolean
timeRemaining(): number
}
interface IDisposable {
dispose(): void
}
/**
* Internal implementation function that handles the actual scheduling logic.
* Uses feature detection to determine whether to use native requestIdleCallback
* or fall back to setTimeout-based implementation.
*/
let _runWhenIdle: (
targetWindow: any,
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
/**
* Execute the callback during the next browser idle period.
* Falls back to setTimeout-based scheduling in browsers without native support.
*/
export let runWhenGlobalIdle: (
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
// Self-invoking function to set up the idle callback implementation
;(function () {
const safeGlobal: any = globalThis
if (
typeof safeGlobal.requestIdleCallback !== 'function' ||
typeof safeGlobal.cancelIdleCallback !== 'function'
) {
// Fallback implementation for browsers without native support (e.g., Safari)
_runWhenIdle = (_targetWindow, runner, _timeout?) => {
setTimeout(() => {
if (disposed) {
return
}
// Simulate IdleDeadline - give 15ms window (one frame at ~64fps)
const end = Date.now() + 15
const deadline: IdleDeadline = {
didTimeout: true,
timeRemaining() {
return Math.max(0, end - Date.now())
}
}
runner(Object.freeze(deadline))
})
let disposed = false
return {
dispose() {
if (disposed) {
return
}
disposed = true
}
}
}
} else {
// Native requestIdleCallback implementation
_runWhenIdle = (targetWindow: typeof safeGlobal, runner, timeout?) => {
const handle: number = targetWindow.requestIdleCallback(
runner,
typeof timeout === 'number' ? { timeout } : undefined
)
let disposed = false
return {
dispose() {
if (disposed) {
return
}
disposed = true
targetWindow.cancelIdleCallback(handle)
}
}
}
}
runWhenGlobalIdle = (runner, timeout) =>
_runWhenIdle(globalThis, runner, timeout)
})()

View File

@@ -43,11 +43,11 @@
<script setup lang="ts">
import Message from 'primevue/message'
import { compare } from 'semver'
import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { compareVersions } from '@/utils/formatUtil'
const props = defineProps<{
missingCoreNodes: Record<string, LGraphNode[]>
@@ -68,7 +68,7 @@ const currentComfyUIVersion = computed<string | null>(() => {
const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
// Sort by version in descending order (newest first)
return compareVersions(b, a) // Reversed for descending order
return compare(b, a) // Reversed for descending order
})
})

View File

@@ -33,7 +33,7 @@
<!-- TransformPane for Vue node rendering -->
<TransformPane
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@transform-update="handleTransformUpdate"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
@@ -76,9 +76,9 @@
import { useEventListener, whenever } from '@vueuse/core'
import {
computed,
nextTick,
onMounted,
onUnmounted,
provide,
ref,
shallowRef,
watch,
@@ -116,7 +116,6 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
@@ -170,17 +169,31 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
// Feature flags
const { shouldRenderVueNodes } = useVueFeatureFlags()
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
// Vue node system
const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled)
const viewportCulling = useViewportCulling(
isVueNodesEnabled,
vueNodeLifecycle.vueNodeData,
vueNodeLifecycle.nodeDataTrigger,
vueNodeLifecycle.nodeManager
const vueNodeLifecycle = useVueNodeLifecycle()
const viewportCulling = useViewportCulling()
const nodeEventHandlers = useNodeEventHandlers()
const handleVueNodeLifecycleReset = async () => {
if (shouldRenderVueNodes.value) {
vueNodeLifecycle.disposeNodeManagerAndSyncs()
await nextTick()
vueNodeLifecycle.initializeNodeManager()
}
}
watch(() => canvasStore.currentGraph, handleVueNodeLifecycleReset)
watch(
() => canvasStore.isInSubgraph,
async (newValue, oldValue) => {
if (oldValue && !newValue) {
useWorkflowStore().updateActiveGraph()
}
await handleVueNodeLifecycleReset()
}
)
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
const nodePositions = vueNodeLifecycle.nodePositions
const nodeSizes = vueNodeLifecycle.nodeSizes
@@ -195,17 +208,6 @@ const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
canvasStore.selectedItems
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
)
provide(SelectedNodeIdsKey, selectedNodeIds)
// Provide execution state to all Vue nodes
useExecutionStateProvider()

View File

@@ -1,5 +1,4 @@
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { t } from '@/i18n'
@@ -39,7 +38,12 @@ export const useCurrentUser = () => {
callback(resolvedUserInfo.value)
}
const stop = whenever(resolvedUserInfo, callback)
const stop = watch(resolvedUserInfo, (value) => {
if (value) {
callback(value)
}
})
return () => stop()
}

View File

@@ -68,7 +68,7 @@ interface SpatialMetrics {
nodesInIndex: number
}
interface GraphNodeManager {
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<string, VueNodeData>
nodeState: ReadonlyMap<string, NodeState>

View File

@@ -6,26 +6,20 @@
* 2. Set display none on element to avoid cascade resolution overhead
* 3. Only run when transform changes (event driven)
*/
import { type Ref, computed } from 'vue'
import { computed } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app as comfyApp } from '@/scripts/app'
interface NodeManager {
getNode: (id: string) => any
}
export function useViewportCulling(
isVueNodesEnabled: Ref<boolean>,
vueNodeData: Ref<ReadonlyMap<string, VueNodeData>>,
nodeDataTrigger: Ref<number>,
nodeManager: Ref<NodeManager | null>
) {
export function useViewportCulling() {
const canvasStore = useCanvasStore()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { vueNodeData, nodeDataTrigger, nodeManager } = useVueNodeLifecycle()
const allNodes = computed(() => {
if (!isVueNodesEnabled.value) return []
if (!shouldRenderVueNodes.value) return []
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
return Array.from(vueNodeData.value.values())
})
@@ -84,7 +78,7 @@ export function useViewportCulling(
* Uses RAF to batch updates for smooth performance
*/
const handleTransformUpdate = () => {
if (!isVueNodesEnabled.value) return
if (!shouldRenderVueNodes.value) return
// Cancel previous RAF if still pending
if (rafId !== null) {

View File

@@ -8,13 +8,16 @@
* - Reactive state management for node data, positions, and sizes
* - Memory management and proper cleanup
*/
import { type Ref, computed, readonly, ref, shallowRef, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { computed, readonly, ref, shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type {
GraphNodeManager,
NodeState,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -24,13 +27,12 @@ import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
import { app as comfyApp } from '@/scripts/app'
export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<ReturnType<typeof useGraphNodeManager> | null>(
null
)
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const cleanupNodeManager = shallowRef<(() => void) | null>(null)
// Sync management
@@ -57,10 +59,12 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
const isNodeManagerReady = computed(() => nodeManager.value !== null)
const initializeNodeManager = () => {
if (!comfyApp.graph || nodeManager.value) return
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph || comfyApp.graph
if (!activeGraph || nodeManager.value) return
// Initialize the core node manager
const manager = useGraphNodeManager(comfyApp.graph)
const manager = useGraphNodeManager(activeGraph)
nodeManager.value = manager
cleanupNodeManager.value = manager.cleanup
@@ -71,8 +75,8 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
nodeSizes.value = manager.nodeSizes
detectChangesInRAF.value = manager.detectChangesInRAF
// Initialize layout system with existing nodes
const nodes = comfyApp.graph._nodes.map((node: LGraphNode) => ({
// Initialize layout system with existing nodes from active graph
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
@@ -80,7 +84,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
layoutStore.initializeFromLiteGraph(nodes)
// Seed reroutes into the Layout Store so hit-testing uses the new path
for (const reroute of comfyApp.graph.reroutes.values()) {
for (const reroute of activeGraph.reroutes.values()) {
const [x, y] = reroute.pos
const parent = reroute.parentId ?? undefined
const linkIds = Array.from(reroute.linkIds)
@@ -88,7 +92,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
}
// Seed existing links into the Layout Store (topology only)
for (const link of comfyApp.graph._links.values()) {
for (const link of activeGraph._links.values()) {
layoutMutations.createLink(
link.id,
link.origin_id,
@@ -142,7 +146,9 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Watch for Vue nodes enabled state changes
watch(
() => isVueNodesEnabled.value && Boolean(comfyApp.graph),
() =>
shouldRenderVueNodes.value &&
Boolean(comfyApp.canvas?.graph || comfyApp.graph),
(enabled) => {
if (enabled) {
initializeNodeManager()
@@ -155,7 +161,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Consolidated watch for slot layout sync management
watch(
[() => canvasStore.canvas, () => isVueNodesEnabled.value],
[() => canvasStore.canvas, () => shouldRenderVueNodes.value],
([canvas, vueMode], [, oldVueMode]) => {
const modeChanged = vueMode !== oldVueMode
@@ -187,7 +193,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
// Handle case where Vue nodes are enabled but graph starts empty
const setupEmptyGraphListener = () => {
if (
isVueNodesEnabled.value &&
shouldRenderVueNodes.value &&
comfyApp.graph &&
!nodeManager.value &&
comfyApp.graph._nodes.length === 0
@@ -198,7 +204,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
comfyApp.graph.onNodeAdded = originalOnNodeAdded
// Initialize node manager if needed
if (isVueNodesEnabled.value && !nodeManager.value) {
if (shouldRenderVueNodes.value && !nodeManager.value) {
initializeNodeManager()
}
@@ -244,3 +250,7 @@ export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
cleanup
}
}
export const useVueNodeLifecycle = createSharedComposable(
useVueNodeLifecycleIndividual
)

View File

@@ -1,7 +1,7 @@
import { compare, valid } from 'semver'
import { computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
export const usePackUpdateStatus = (
@@ -16,14 +16,14 @@ export const usePackUpdateStatus = (
const latestVersion = computed(() => nodePack.latest_version?.version)
const isNightlyPack = computed(
() => !!installedVersion.value && !isSemVer(installedVersion.value)
() => !!installedVersion.value && !valid(installedVersion.value)
)
const isUpdateAvailable = computed(() => {
if (!isInstalled.value || isNightlyPack.value || !latestVersion.value) {
return false
}
return compareVersions(latestVersion.value, installedVersion.value) > 0
return compare(latestVersion.value, installedVersion.value) > 0
})
return {

View File

@@ -1,8 +1,8 @@
import { compare, valid } from 'semver'
import { computed, onMounted } from 'vue'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
/**
@@ -25,13 +25,13 @@ export const useUpdateAvailableNodes = () => {
)
const latestVersion = pack.latest_version?.version
const isNightlyPack = !!installedVersion && !isSemVer(installedVersion)
const isNightlyPack = !!installedVersion && !valid(installedVersion)
if (isNightlyPack || !latestVersion) {
return false
}
return compareVersions(latestVersion, installedVersion) > 0
return compare(latestVersion, installedVersion) > 0
}
// Same filtering logic as ManagerDialogContent.vue

View File

@@ -2,16 +2,17 @@
* Vue-related feature flags composable
* Manages local settings-driven flags and LiteGraph integration
*/
import { createSharedComposable } from '@vueuse/core'
import { computed, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { LiteGraph } from '../lib/litegraph/src/litegraph'
export const useVueFeatureFlags = () => {
function useVueFeatureFlagsIndividual() {
const settingStore = useSettingStore()
const isVueNodesEnabled = computed(() => {
const shouldRenderVueNodes = computed(() => {
try {
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
} catch {
@@ -19,20 +20,20 @@ export const useVueFeatureFlags = () => {
}
})
// Whether Vue nodes should render
const shouldRenderVueNodes = computed(() => isVueNodesEnabled.value)
// Sync the Vue nodes flag with LiteGraph global settings
const syncVueNodesFlag = () => {
LiteGraph.vueNodesMode = isVueNodesEnabled.value
}
// Watch for changes and update LiteGraph immediately
watch(isVueNodesEnabled, syncVueNodesFlag, { immediate: true })
watch(
shouldRenderVueNodes,
() => {
LiteGraph.vueNodesMode = shouldRenderVueNodes.value
},
{ immediate: true }
)
return {
isVueNodesEnabled,
shouldRenderVueNodes,
syncVueNodesFlag
shouldRenderVueNodes
}
}
export const useVueFeatureFlags = createSharedComposable(
useVueFeatureFlagsIndividual
)

View File

@@ -76,7 +76,7 @@ export const i18n = createI18n({
})
/** Convenience shorthand: i18n.global */
export const { t, te } = i18n.global
export const { t, te, d } = i18n.global
/**
* Safe translation function that returns the fallback message if the key is not found.

View File

@@ -140,7 +140,7 @@ export { BaseWidget } from './widgets/BaseWidget'
export { LegacyWidget } from './widgets/LegacyWidget'
export { isComboWidget } from './widgets/widgetMap'
export { isComboWidget, isAssetWidget } from './widgets/widgetMap'
// Additional test-specific exports
export { LGraphButton } from './LGraphButton'
export { MovingOutputLink } from './canvas/MovingOutputLink'

View File

@@ -13,6 +13,22 @@ export class AssetWidget
this.value = widget.value?.toString() ?? ''
}
override set value(value: IAssetWidget['value']) {
const oldValue = this.value
super.value = value
// Force canvas redraw when value changes to show update immediately
if (oldValue !== value && this.node.graph?.list_of_graphcanvas) {
for (const canvas of this.node.graph.list_of_graphcanvas) {
canvas.setDirty(true)
}
}
}
override get value(): IAssetWidget['value'] {
return super.value
}
override get _displayValue(): string {
return String(this.value) //FIXME: Resolve asset name
}

View File

@@ -1,5 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
IAssetWidget,
IBaseWidget,
IComboWidget,
IWidget,
@@ -132,4 +133,9 @@ export function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
return widget.type === 'combo'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IAssetWidget}. */
export function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
return widget.type === 'asset'
}
// #endregion Type Guards

View File

@@ -1873,6 +1873,13 @@
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Search assets...",
"allModels": "All Models",
"unknown": "Unknown"
"unknown": "Unknown",
"fileFormats": "File formats",
"baseModels": "Base models",
"sortBy": "Sort by",
"sortAZ": "A-Z",
"sortZA": "Z-A",
"sortRecent": "Recent",
"sortPopular": "Popular"
}
}

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import {
createMockAssets,
mockAssets
@@ -56,7 +57,7 @@ export const Default: Story = {
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
const onAssetSelect = (asset: AssetDisplayItem) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
@@ -96,7 +97,7 @@ export const SingleAssetType: Story = {
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
const onAssetSelect = (asset: AssetDisplayItem) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
@@ -145,7 +146,7 @@ export const NoLeftPanel: Story = {
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
const onAssetSelect = (asset: AssetDisplayItem) => {
console.log('Selected asset:', asset)
}
const onClose = () => {

View File

@@ -12,7 +12,7 @@
:nav-items="availableCategories"
>
<template #header-icon>
<i-lucide:folder class="size-4" />
<div class="icon-[lucide--folder] size-4" />
</template>
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
</LeftSidePanel>
@@ -37,7 +37,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, provide } from 'vue'
import SearchBox from '@/components/input/SearchBox.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
@@ -46,6 +46,7 @@ import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { OnCloseKey } from '@/types/widgetTypes'
const props = defineProps<{
nodeType?: string
@@ -61,35 +62,28 @@ const emit = defineEmits<{
close: []
}>()
// Use AssetBrowser composable for all business logic
provide(OnCloseKey, props.onClose ?? (() => {}))
const {
searchQuery,
selectedCategory,
availableCategories,
contentTitle,
filteredAssets,
selectAsset
selectAssetWithCallback
} = useAssetBrowser(props.assets)
// Dialog controls panel visibility via prop
const shouldShowLeftPanel = computed(() => {
return props.showLeftPanel ?? true
})
// Handle close button - call both the prop callback and emit the event
const handleClose = () => {
props.onClose?.()
emit('close')
}
// Handle asset selection and emit to parent
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
selectAsset(asset) // This logs the selection for dev mode
emit('asset-select', asset) // Emit the full asset object
// Call prop callback if provided
if (props.onSelect) {
props.onSelect(asset.name) // Use asset name as the asset path
}
const handleAssetSelectAndEmit = async (asset: AssetDisplayItem) => {
emit('asset-select', asset)
await selectAssetWithCallback(asset.id, props.onSelect)
}
</script>

View File

@@ -14,7 +14,7 @@
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
'focus:outline-none focus:transform focus:-translate-y-0.5 focus:shadow-lg focus:shadow-black/10 dark-theme:focus:shadow-black/30'
],
// Div-specific styles
!interactive && [

View File

@@ -3,7 +3,7 @@
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
<MultiSelect
v-model="fileFormats"
label="File formats"
:label="$t('assetBrowser.fileFormats')"
:options="fileFormatOptions"
:class="selectClasses"
data-component-id="asset-filter-file-formats"
@@ -12,7 +12,7 @@
<MultiSelect
v-model="baseModels"
label="Base models"
:label="$t('assetBrowser.baseModels')"
:options="baseModelOptions"
:class="selectClasses"
data-component-id="asset-filter-base-models"
@@ -23,7 +23,7 @@
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
<SingleSelect
v-model="sortBy"
label="Sort by"
:label="$t('assetBrowser.sortBy')"
:options="sortOptions"
:class="selectClasses"
data-component-id="asset-filter-sort"
@@ -43,6 +43,7 @@ import { ref } from 'vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { SelectOption } from '@/components/input/types'
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
export interface FilterState {
@@ -74,10 +75,10 @@ const baseModelOptions = [
// TODO: Make sortOptions configurable via props
// Different asset types might need different sorting options
const sortOptions = [
{ name: 'A-Z', value: 'name-asc' },
{ name: 'Z-A', value: 'name-desc' },
{ name: 'Recent', value: 'recent' },
{ name: 'Popular', value: 'popular' }
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
{ name: t('assetBrowser.sortZA'), value: 'name-desc' },
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
{ name: t('assetBrowser.sortPopular'), value: 'popular' }
]
const emit = defineEmits<{

View File

@@ -1,8 +1,9 @@
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { d, t } from '@/i18n'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetFilenameSchema } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import {
getAssetBaseModel,
getAssetDescription
@@ -69,7 +70,7 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
// Create display stats from API data
const stats = {
formattedDate: new Date(asset.created_at).toLocaleDateString(),
formattedDate: d(new Date(asset.created_at), { dateStyle: 'short' }),
downloadCount: undefined, // Not available in API
stars: undefined // Not available in API
}
@@ -162,12 +163,41 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
return filtered.map(transformAssetForDisplay)
})
// Actions
function selectAsset(asset: AssetDisplayItem): UUID {
/**
* Asset selection that fetches full details and executes callback with filename
* @param assetId - The asset ID to select and fetch details for
* @param onSelect - Optional callback to execute with the asset filename
*/
async function selectAssetWithCallback(
assetId: string,
onSelect?: (filename: string) => void
): Promise<void> {
if (import.meta.env.DEV) {
console.log('Asset selected:', asset.id, asset.name)
console.debug('Asset selected:', assetId)
}
if (!onSelect) {
return
}
try {
const detailAsset = await assetService.getAssetDetails(assetId)
const filename = detailAsset.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
assetId
)
return
}
onSelect(validatedFilename.data)
} catch (error) {
console.error(`Failed to fetch asset details for ${assetId}:`, error)
}
return asset.id
}
return {
@@ -182,7 +212,6 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
filteredAssets,
// Actions
selectAsset,
transformAssetForDisplay
selectAssetWithCallback
}
}

View File

@@ -1,5 +1,7 @@
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import { useDialogStore } from '@/stores/dialogStore'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
interface AssetBrowserDialogProps {
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
@@ -8,36 +10,29 @@ interface AssetBrowserDialogProps {
inputName: string
/** Current selected asset value */
currentValue?: string
/** Callback for when an asset is selected */
onAssetSelected?: (assetPath: string) => void
/**
* Callback for when an asset is selected
* @param {string} filename - The validated filename from user_metadata.filename
*/
onAssetSelected?: (filename: string) => void
}
export const useAssetBrowserDialog = () => {
const dialogStore = useDialogStore()
const dialogKey = 'global-asset-browser'
function hide() {
dialogStore.closeDialog({ key: dialogKey })
}
function show(props: AssetBrowserDialogProps) {
const handleAssetSelected = (assetPath: string) => {
props.onAssetSelected?.(assetPath)
hide() // Auto-close on selection
async function show(props: AssetBrowserDialogProps) {
const handleAssetSelected = (filename: string) => {
props.onAssetSelected?.(filename)
dialogStore.closeDialog({ key: dialogKey })
}
const handleClose = () => {
hide()
}
// Default dialog configuration for AssetBrowserModal
const dialogComponentProps = {
const dialogComponentProps: DialogComponentProps = {
headless: true,
modal: true,
closable: false,
closable: true,
pt: {
root: {
class: 'rounded-2xl overflow-hidden'
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
},
header: {
class: 'p-0 hidden'
@@ -48,6 +43,17 @@ export const useAssetBrowserDialog = () => {
}
}
const assets: AssetItem[] = await assetService
.getAssetsForNodeType(props.nodeType)
.catch((error) => {
console.error(
'Failed to fetch assets for node type:',
props.nodeType,
error
)
return []
})
dialogStore.showDialog({
key: dialogKey,
component: AssetBrowserModal,
@@ -55,12 +61,13 @@ export const useAssetBrowserDialog = () => {
nodeType: props.nodeType,
inputName: props.inputName,
currentValue: props.currentValue,
assets,
onSelect: handleAssetSelected,
onClose: handleClose
onClose: () => dialogStore.closeDialog({ key: dialogKey })
},
dialogComponentProps
})
}
return { show, hide }
return { show }
}

View File

@@ -4,13 +4,13 @@ import { z } from 'zod'
const zAsset = z.object({
id: z.string(),
name: z.string(),
asset_hash: z.string(),
asset_hash: z.string().nullable(),
size: z.number(),
mime_type: z.string(),
mime_type: z.string().nullable(),
tags: z.array(z.string()),
preview_url: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
updated_at: z.string().optional(),
last_access_time: z.string(),
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
preview_id: z.string().nullable().optional()
@@ -33,6 +33,14 @@ const zModelFile = z.object({
pathIndex: z.number()
})
// Filename validation schema
export const assetFilenameSchema = z
.string()
.min(1, 'Filename cannot be empty')
.regex(/^[^\\:*?"<>|]+$/, 'Invalid filename characters') // Allow forward slashes, block backslashes and other unsafe chars
.regex(/^(?!\/|.*\.\.)/, 'Path must not start with / or contain ..') // Prevent absolute paths and directory traversal
.trim()
// Export schemas following repository patterns
export const assetResponseSchema = zAssetResponse

View File

@@ -1,6 +1,7 @@
import { fromZodError } from 'zod-validation-error'
import {
type AssetItem,
type AssetResponse,
type ModelFile,
type ModelFolder,
@@ -127,10 +128,75 @@ function createAssetService() {
)
}
/**
* Gets assets for a specific node type by finding the matching category
* and fetching all assets with that category tag
*
* @param nodeType - The ComfyUI node type (e.g., 'CheckpointLoaderSimple')
* @returns Promise<AssetItem[]> - Full asset objects with preserved metadata
*/
async function getAssetsForNodeType(nodeType: string): Promise<AssetItem[]> {
if (!nodeType || typeof nodeType !== 'string') {
return []
}
// Find the category for this node type using efficient O(1) lookup
const modelToNodeStore = useModelToNodeStore()
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
if (!category) {
return []
}
// Fetch assets for this category using same API pattern as getAssetModels
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}`,
`assets for ${nodeType}`
)
// Return full AssetItem[] objects (don't strip like getAssetModels does)
return (
data?.assets?.filter(
(asset) =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
) ?? []
)
}
/**
* Gets complete details for a specific asset by ID
* Calls the detail endpoint which includes user_metadata and all fields
*
* @param id - The asset ID
* @returns Promise<AssetItem> - Complete asset object with user_metadata
*/
async function getAssetDetails(id: string): Promise<AssetItem> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`)
if (!res.ok) {
throw new Error(
`Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
)
}
const data = await res.json()
// Validate the single asset response against our schema
const result = assetResponseSchema.safeParse({ assets: [data] })
if (result.success && result.data.assets?.[0]) {
return result.data.assets[0]
}
const error = result.error
? fromZodError(result.error)
: 'Unknown validation error'
throw new Error(`Invalid asset response against zod schema:\n${error}`)
}
return {
getAssetModelFolders,
getAssetModels,
isAssetBrowserEligible
isAssetBrowserEligible,
getAssetsForNodeType,
getAssetDetails
}
}

View File

@@ -1,5 +1,6 @@
import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { compare, valid } from 'semver'
import { ref } from 'vue'
import type { SettingParams } from '@/platform/settings/types'
@@ -7,7 +8,6 @@ import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
export const getSettingInfo = (setting: SettingParams) => {
const parts = setting.category || setting.id.split('.')
@@ -132,20 +132,25 @@ export const useSettingStore = defineStore('setting', () => {
if (installedVersion) {
const sortedVersions = Object.keys(defaultsByInstallVersion).sort(
(a, b) => compareVersions(b, a)
(a, b) => compare(b, a)
)
for (const version of sortedVersions) {
// Ensure the version is in a valid format before comparing
if (!isSemVer(version)) {
if (!valid(version)) {
continue
}
if (compareVersions(installedVersion, version) >= 0) {
const versionedDefault = defaultsByInstallVersion[version]
return typeof versionedDefault === 'function'
? versionedDefault()
: versionedDefault
if (compare(installedVersion, version) >= 0) {
const versionedDefault =
defaultsByInstallVersion[
version as keyof typeof defaultsByInstallVersion
]
if (versionedDefault !== undefined) {
return typeof versionedDefault === 'function'
? versionedDefault()
: versionedDefault
}
}
}
}

View File

@@ -1,11 +1,12 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia'
import { compare } from 'semver'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
import { compareVersions, stringToLocale } from '@/utils/formatUtil'
import { stringToLocale } from '@/utils/formatUtil'
import { type ReleaseNote, useReleaseService } from './releaseService'
@@ -56,16 +57,19 @@ export const useReleaseStore = defineStore('release', () => {
const isNewVersionAvailable = computed(
() =>
!!recentRelease.value &&
compareVersions(
compare(
recentRelease.value.version,
currentComfyUIVersion.value
currentComfyUIVersion.value || '0.0.0'
) > 0
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
!compareVersions(recentRelease.value.version, currentComfyUIVersion.value)
compare(
recentRelease.value.version,
currentComfyUIVersion.value || '0.0.0'
) === 0
)
const hasMediumOrHighAttention = computed(() =>

View File

@@ -1,6 +1,6 @@
import { until, useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import * as semver from 'semver'
import { gt, valid } from 'semver'
import { computed } from 'vue'
import config from '@/config'
@@ -26,13 +26,13 @@ export const useVersionCompatibilityStore = defineStore(
if (
!frontendVersion.value ||
!requiredFrontendVersion.value ||
!semver.valid(frontendVersion.value) ||
!semver.valid(requiredFrontendVersion.value)
!valid(frontendVersion.value) ||
!valid(requiredFrontendVersion.value)
) {
return false
}
// Returns true if required version is greater than frontend version
return semver.gt(requiredFrontendVersion.value, frontendVersion.value)
return gt(requiredFrontendVersion.value, frontendVersion.value)
})
const isFrontendNewer = computed(() => {

View File

@@ -1,8 +1,10 @@
import { useEventListener, whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
import type {
LGraph,
LGraphCanvas,
LGraphGroup,
LGraphNode
@@ -94,9 +96,43 @@ export const useCanvasStore = defineStore('canvas', () => {
appScalePercentage.value = Math.round(newScale * 100)
}
const currentGraph = shallowRef<LGraph | null>(null)
const isInSubgraph = ref(false)
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
selectedItems.value
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
)
whenever(
() => canvas.value,
(newCanvas) => {
useEventListener(
newCanvas.canvas,
'litegraph:set-graph',
(event: CustomEvent<{ newGraph: LGraph; oldGraph: LGraph }>) => {
const newGraph = event.detail?.newGraph || app.canvas?.graph
currentGraph.value = newGraph
isInSubgraph.value = Boolean(app.canvas?.subgraph)
}
)
useEventListener(newCanvas.canvas, 'subgraph-opened', () => {
isInSubgraph.value = true
})
},
{ immediate: true }
)
return {
canvas,
selectedItems,
selectedNodeIds,
nodeSelected,
groupSelected,
rerouteSelected,
@@ -105,6 +141,8 @@ export const useCanvasStore = defineStore('canvas', () => {
getCanvas,
setAppZoomFromPercentage,
initScaleSync,
cleanupScaleSync
cleanupScaleSync,
currentGraph,
isInSubgraph
}
})

View File

@@ -2,13 +2,6 @@ import type { InjectionKey, Ref } from 'vue'
import type { NodeProgressState } from '@/schemas/apiSchema'
/**
* Injection key for providing selected node IDs to Vue node components.
* Contains a reactive Set of selected node IDs (as strings).
*/
export const SelectedNodeIdsKey: InjectionKey<Ref<Set<string>>> =
Symbol('selectedNodeIds')
/**
* Injection key for providing executing node IDs to Vue node components.
* Contains a reactive Set of currently executing node IDs (as strings).

View File

@@ -1,11 +1,13 @@
import { useThrottleFn } from '@vueuse/core'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { api } from '@/scripts/api'
import { MinimapDataSourceFactory } from '../data/MinimapDataSourceFactory'
import type { UpdateFlags } from '../types'
interface GraphCallbacks {
@@ -28,6 +30,9 @@ export function useMinimapGraph(
viewport: false
})
// Track LayoutStore version for change detection
const layoutStoreVersion = layoutStore.getVersion()
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
@@ -96,28 +101,30 @@ export function useMinimapGraph(
let positionChanged = false
let connectionChanged = false
if (g._nodes.length !== lastNodeCount.value) {
// Use unified data source for change detection
const dataSource = MinimapDataSourceFactory.create(g)
// Check for node count changes
const currentNodeCount = dataSource.getNodeCount()
if (currentNodeCount !== lastNodeCount.value) {
structureChanged = true
lastNodeCount.value = g._nodes.length
lastNodeCount.value = currentNodeCount
}
for (const node of g._nodes) {
const key = node.id
const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
// Check for node position/size changes
const nodes = dataSource.getNodes()
for (const node of nodes) {
const nodeId = node.id
const currentState = `${node.x},${node.y},${node.width},${node.height}`
if (nodeStatesCache.get(key) !== currentState) {
if (nodeStatesCache.get(nodeId) !== currentState) {
positionChanged = true
nodeStatesCache.set(key, currentState)
nodeStatesCache.set(nodeId, currentState)
}
}
const currentLinks = JSON.stringify(g.links || {})
if (currentLinks !== linksCache.value) {
connectionChanged = true
linksCache.value = currentLinks
}
const currentNodeIds = new Set(g._nodes.map((n: LGraphNode) => n.id))
// Clean up removed nodes from cache
const currentNodeIds = new Set(nodes.map((n) => n.id))
for (const [nodeId] of nodeStatesCache) {
if (!currentNodeIds.has(nodeId)) {
nodeStatesCache.delete(nodeId)
@@ -125,6 +132,13 @@ export function useMinimapGraph(
}
}
// TODO: update when Layoutstore tracks links
const currentLinks = JSON.stringify(g.links || {})
if (currentLinks !== linksCache.value) {
connectionChanged = true
linksCache.value = currentLinks
}
if (structureChanged || positionChanged) {
updateFlags.value.bounds = true
updateFlags.value.nodes = true
@@ -140,6 +154,10 @@ export function useMinimapGraph(
const init = () => {
setupEventListeners()
api.addEventListener('graphChanged', handleGraphChangedThrottled)
watch(layoutStoreVersion, () => {
void handleGraphChangedThrottled()
})
}
const destroy = () => {

View File

@@ -5,9 +5,9 @@ import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformS
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateMinimapScale,
calculateNodeBounds,
enforceMinimumBounds
} from '@/renderer/core/spatial/boundsCalculator'
import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory'
import type { MinimapBounds, MinimapCanvas, ViewportTransform } from '../types'
@@ -53,17 +53,15 @@ export function useMinimapViewport(
}
const calculateGraphBounds = (): MinimapBounds => {
const g = graph.value
if (!g || !g._nodes || g._nodes.length === 0) {
// Use unified data source
const dataSource = MinimapDataSourceFactory.create(graph.value)
if (!dataSource.hasData()) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
const bounds = calculateNodeBounds(g._nodes)
if (!bounds) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
return enforceMinimumBounds(bounds)
const sourceBounds = dataSource.getBounds()
return enforceMinimumBounds(sourceBounds)
}
const calculateScale = () => {

View File

@@ -0,0 +1,95 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
import type {
IMinimapDataSource,
MinimapBounds,
MinimapGroupData,
MinimapLinkData,
MinimapNodeData
} from '../types'
/**
* Abstract base class for minimap data sources
* Provides common functionality and shared implementation
*/
export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
constructor(protected graph: LGraph | null) {}
// Abstract methods that must be implemented by subclasses
abstract getNodes(): MinimapNodeData[]
abstract getNodeCount(): number
abstract hasData(): boolean
// Shared implementation using calculateNodeBounds
getBounds(): MinimapBounds {
const nodes = this.getNodes()
if (nodes.length === 0) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
// Convert MinimapNodeData to the format expected by calculateNodeBounds
const compatibleNodes = nodes.map((node) => ({
pos: [node.x, node.y],
size: [node.width, node.height]
}))
const bounds = calculateNodeBounds(compatibleNodes)
if (!bounds) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
return bounds
}
// Shared implementation for groups
getGroups(): MinimapGroupData[] {
if (!this.graph?._groups) return []
return this.graph._groups.map((group) => ({
x: group.pos[0],
y: group.pos[1],
width: group.size[0],
height: group.size[1],
color: group.color
}))
}
// TODO: update when Layoutstore supports links
getLinks(): MinimapLinkData[] {
if (!this.graph) return []
return this.extractLinksFromGraph(this.graph)
}
protected extractLinksFromGraph(graph: LGraph): MinimapLinkData[] {
const links: MinimapLinkData[] = []
const nodeMap = new Map(this.getNodes().map((n) => [n.id, n]))
for (const node of graph._nodes) {
if (!node.outputs) continue
const sourceNodeData = nodeMap.get(String(node.id))
if (!sourceNodeData) continue
for (const output of node.outputs) {
if (!output.links) continue
for (const linkId of output.links) {
const link = graph.links[linkId]
if (!link) continue
const targetNodeData = nodeMap.get(String(link.target_id))
if (!targetNodeData) continue
links.push({
sourceNode: sourceNodeData,
targetNode: targetNodeData,
sourceSlot: link.origin_slot,
targetSlot: link.target_slot
})
}
}
}
return links
}
}

View File

@@ -0,0 +1,42 @@
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { MinimapNodeData } from '../types'
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
/**
* Layout Store data source implementation
*/
export class LayoutStoreDataSource extends AbstractMinimapDataSource {
getNodes(): MinimapNodeData[] {
const allNodes = layoutStore.getAllNodes().value
if (allNodes.size === 0) return []
const nodes: MinimapNodeData[] = []
for (const [nodeId, layout] of allNodes) {
// Find corresponding LiteGraph node for additional properties
const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId)
nodes.push({
id: nodeId,
x: layout.position.x,
y: layout.position.y,
width: layout.size.width,
height: layout.size.height,
bgcolor: graphNode?.bgcolor,
mode: graphNode?.mode,
hasErrors: graphNode?.has_errors
})
}
return nodes
}
getNodeCount(): number {
return layoutStore.getAllNodes().value.size
}
hasData(): boolean {
return this.getNodeCount() > 0
}
}

View File

@@ -0,0 +1,30 @@
import type { MinimapNodeData } from '../types'
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
/**
* LiteGraph data source implementation
*/
export class LiteGraphDataSource extends AbstractMinimapDataSource {
getNodes(): MinimapNodeData[] {
if (!this.graph?._nodes) return []
return this.graph._nodes.map((node) => ({
id: String(node.id),
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1],
bgcolor: node.bgcolor,
mode: node.mode,
hasErrors: node.has_errors
}))
}
getNodeCount(): number {
return this.graph?._nodes?.length ?? 0
}
hasData(): boolean {
return this.getNodeCount() > 0
}
}

View File

@@ -0,0 +1,22 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { IMinimapDataSource } from '../types'
import { LayoutStoreDataSource } from './LayoutStoreDataSource'
import { LiteGraphDataSource } from './LiteGraphDataSource'
/**
* Factory for creating the appropriate data source
*/
export class MinimapDataSourceFactory {
static create(graph: LGraph | null): IMinimapDataSource {
// Check if LayoutStore has data
const layoutStoreHasData = layoutStore.getAllNodes().value.size > 0
if (layoutStoreHasData) {
return new LayoutStoreDataSource(graph)
}
return new LiteGraphDataSource(graph)
}
}

View File

@@ -3,7 +3,12 @@ import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import type { MinimapRenderContext } from './types'
import { MinimapDataSourceFactory } from './data/MinimapDataSourceFactory'
import type {
IMinimapDataSource,
MinimapNodeData,
MinimapRenderContext
} from './types'
/**
* Get theme-aware colors for the minimap
@@ -25,24 +30,49 @@ function getMinimapColors() {
}
}
/**
* Get node color based on settings and node properties (Single Responsibility)
*/
function getNodeColor(
node: MinimapNodeData,
settings: MinimapRenderContext['settings'],
colors: ReturnType<typeof getMinimapColors>
): string {
if (settings.renderBypass && node.mode === LGraphEventMode.BYPASS) {
return colors.bypassColor
}
if (settings.nodeColors) {
if (node.bgcolor) {
return colors.isLightTheme
? adjustColor(node.bgcolor, { lightness: 0.5 })
: node.bgcolor
}
return colors.nodeColorDefault
}
return colors.nodeColor
}
/**
* Render groups on the minimap
*/
function renderGroups(
ctx: CanvasRenderingContext2D,
graph: LGraph,
dataSource: IMinimapDataSource,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType<typeof getMinimapColors>
) {
if (!graph._groups || graph._groups.length === 0) return
const groups = dataSource.getGroups()
if (groups.length === 0) return
for (const group of graph._groups) {
const x = (group.pos[0] - context.bounds.minX) * context.scale + offsetX
const y = (group.pos[1] - context.bounds.minY) * context.scale + offsetY
const w = group.size[0] * context.scale
const h = group.size[1] * context.scale
for (const group of groups) {
const x = (group.x - context.bounds.minX) * context.scale + offsetX
const y = (group.y - context.bounds.minY) * context.scale + offsetY
const w = group.width * context.scale
const h = group.height * context.scale
let color = colors.groupColor
@@ -64,45 +94,34 @@ function renderGroups(
*/
function renderNodes(
ctx: CanvasRenderingContext2D,
graph: LGraph,
dataSource: IMinimapDataSource,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType<typeof getMinimapColors>
) {
if (!graph._nodes || graph._nodes.length === 0) return
const nodes = dataSource.getNodes()
if (nodes.length === 0) return
// Group nodes by color for batch rendering
// Group nodes by color for batch rendering (performance optimization)
const nodesByColor = new Map<
string,
Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }>
>()
for (const node of graph._nodes) {
const x = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
const y = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
const w = node.size[0] * context.scale
const h = node.size[1] * context.scale
for (const node of nodes) {
const x = (node.x - context.bounds.minX) * context.scale + offsetX
const y = (node.y - context.bounds.minY) * context.scale + offsetY
const w = node.width * context.scale
const h = node.height * context.scale
let color = colors.nodeColor
if (context.settings.renderBypass && node.mode === LGraphEventMode.BYPASS) {
color = colors.bypassColor
} else if (context.settings.nodeColors) {
color = colors.nodeColorDefault
if (node.bgcolor) {
color = colors.isLightTheme
? adjustColor(node.bgcolor, { lightness: 0.5 })
: node.bgcolor
}
}
const color = getNodeColor(node, context.settings, colors)
if (!nodesByColor.has(color)) {
nodesByColor.set(color, [])
}
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.has_errors })
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.hasErrors })
}
// Batch render nodes by color
@@ -132,13 +151,14 @@ function renderNodes(
*/
function renderConnections(
ctx: CanvasRenderingContext2D,
graph: LGraph,
dataSource: IMinimapDataSource,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType<typeof getMinimapColors>
) {
if (!graph || !graph._nodes) return
const links = dataSource.getLinks()
if (links.length === 0) return
ctx.strokeStyle = colors.linkColor
ctx.lineWidth = 0.3
@@ -151,41 +171,28 @@ function renderConnections(
y2: number
}> = []
for (const node of graph._nodes) {
if (!node.outputs) continue
for (const link of links) {
const x1 =
(link.sourceNode.x - context.bounds.minX) * context.scale + offsetX
const y1 =
(link.sourceNode.y - context.bounds.minY) * context.scale + offsetY
const x2 =
(link.targetNode.x - context.bounds.minX) * context.scale + offsetX
const y2 =
(link.targetNode.y - context.bounds.minY) * context.scale + offsetY
const x1 = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
const y1 = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
const outputX = x1 + link.sourceNode.width * context.scale
const outputY = y1 + link.sourceNode.height * context.scale * 0.2
const inputX = x2
const inputY = y2 + link.targetNode.height * context.scale * 0.2
for (const output of node.outputs) {
if (!output.links) continue
// Draw connection line
ctx.beginPath()
ctx.moveTo(outputX, outputY)
ctx.lineTo(inputX, inputY)
ctx.stroke()
for (const linkId of output.links) {
const link = graph.links[linkId]
if (!link) continue
const targetNode = graph.getNodeById(link.target_id)
if (!targetNode) continue
const x2 =
(targetNode.pos[0] - context.bounds.minX) * context.scale + offsetX
const y2 =
(targetNode.pos[1] - context.bounds.minY) * context.scale + offsetY
const outputX = x1 + node.size[0] * context.scale
const outputY = y1 + node.size[1] * context.scale * 0.2
const inputX = x2
const inputY = y2 + targetNode.size[1] * context.scale * 0.2
// Draw connection line
ctx.beginPath()
ctx.moveTo(outputX, outputY)
ctx.lineTo(inputX, inputY)
ctx.stroke()
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
}
}
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
}
// Render connection slots on top
@@ -217,8 +224,11 @@ export function renderMinimapToCanvas(
// Clear canvas
ctx.clearRect(0, 0, context.width, context.height)
// Create unified data source (Dependency Inversion)
const dataSource = MinimapDataSourceFactory.create(graph)
// Fast path for empty graph
if (!graph || !graph._nodes || graph._nodes.length === 0) {
if (!dataSource.hasData()) {
return
}
@@ -228,12 +238,12 @@ export function renderMinimapToCanvas(
// Render in correct order: groups -> links -> nodes
if (context.settings.showGroups) {
renderGroups(ctx, graph, offsetX, offsetY, context, colors)
renderGroups(ctx, dataSource, offsetX, offsetY, context, colors)
}
if (context.settings.showLinks) {
renderConnections(ctx, graph, offsetX, offsetY, context, colors)
renderConnections(ctx, dataSource, offsetX, offsetY, context, colors)
}
renderNodes(ctx, graph, offsetX, offsetY, context, colors)
renderNodes(ctx, dataSource, offsetX, offsetY, context, colors)
}

View File

@@ -2,6 +2,7 @@
* Minimap-specific type definitions
*/
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* Minimal interface for what the minimap needs from the canvas
@@ -66,3 +67,50 @@ export type MinimapSettingsKey =
| 'Comfy.Minimap.ShowGroups'
| 'Comfy.Minimap.RenderBypassState'
| 'Comfy.Minimap.RenderErrorState'
/**
* Node data required for minimap rendering
*/
export interface MinimapNodeData {
id: NodeId
x: number
y: number
width: number
height: number
bgcolor?: string
mode?: number
hasErrors?: boolean
}
/**
* Link data required for minimap rendering
*/
export interface MinimapLinkData {
sourceNode: MinimapNodeData
targetNode: MinimapNodeData
sourceSlot: number
targetSlot: number
}
/**
* Group data required for minimap rendering
*/
export interface MinimapGroupData {
x: number
y: number
width: number
height: number
color?: string
}
/**
* Interface for minimap data sources (Dependency Inversion Principle)
*/
export interface IMinimapDataSource {
getNodes(): MinimapNodeData[]
getLinks(): MinimapLinkData[]
getGroups(): MinimapGroupData[]
getBounds(): MinimapBounds
getNodeCount(): number
hasData(): boolean
}

View File

@@ -55,6 +55,7 @@
:collapsed="isCollapsed"
@collapse="handleCollapse"
@update:title="handleTitleUpdate"
@enter-subgraph="handleEnterSubgraph"
/>
</div>
@@ -138,12 +139,12 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import {
computed,
inject,
onErrorCaptured,
onMounted,
provide,
ref,
toRef,
watch
@@ -152,10 +153,11 @@ import {
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
@@ -164,10 +166,12 @@ import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
@@ -186,8 +190,8 @@ interface LGraphNodeProps {
const {
nodeData,
position,
size,
position = { x: 0, y: 0 },
size = { width: 100, height: 50 },
error = null,
readonly = false,
zoomLevel = 1
@@ -205,20 +209,13 @@ const emit = defineEmits<{
slotIndex: number,
isInput: boolean
]
dragStart: [event: DragEvent, nodeData: VueNodeData]
'update:collapsed': [nodeId: string, collapsed: boolean]
'update:title': [nodeId: string, newTitle: string]
}>()
useVueElementTracking(nodeData.id, 'node')
// Inject selection state from parent
const selectedNodeIds = inject(SelectedNodeIdsKey)
if (!selectedNodeIds) {
throw new Error(
'SelectedNodeIds not provided - LGraphNode must be used within a component that provides selection state'
)
}
const { selectedNodeIds } = storeToRefs(useCanvasStore())
// Inject transform state for coordinate conversion
const transformState = inject(TransformStateKey)
@@ -245,12 +242,7 @@ const hasAnyError = computed(
const bypassed = computed((): boolean => nodeData.mode === 4)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const {
handleWheel,
handlePointer,
forwardEventToCanvas,
shouldHandleNodePointerEvents
} = useCanvasInteractions()
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
// LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => zoomLevel)
@@ -276,14 +268,16 @@ onErrorCaptured((error) => {
})
// Use layout system for node position and dragging
const { position: layoutPosition, zIndex, resize } = useNodeLayout(nodeData.id)
const {
position: layoutPosition,
zIndex,
startDrag,
handleDrag: handleLayoutDrag,
endDrag,
resize
} = useNodeLayout(nodeData.id)
handlePointerDown,
handlePointerUp,
handlePointerMove,
isDragging,
dragStyle
} = useNodePointerInteractions(nodeData, (event, nodeData, wasDragging) => {
emit('node-click', event, nodeData, wasDragging)
})
onMounted(() => {
if (size && transformState?.camera) {
@@ -296,16 +290,6 @@ onMounted(() => {
}
})
// Drag state for styling
const isDragging = ref(false)
const dragStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
const lastY = ref(0)
const lastX = ref(0)
// Treat tiny pointer jitter as a click, not a drag
const DRAG_THRESHOLD_PX = 4
// Track collapsed state
const isCollapsed = ref(nodeData.flags?.collapsed ?? false)
@@ -371,60 +355,6 @@ const outlineClass = computed(() => {
})
// Event handlers
const handlePointerDown = (event: PointerEvent) => {
if (!nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
return
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Start drag using layout system
isDragging.value = true
// Set Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = true
startDrag(event)
lastY.value = event.clientY
lastX.value = event.clientX
}
const handlePointerMove = (event: PointerEvent) => {
// Check if this should be forwarded to canvas (e.g., space panning, middle mouse)
handlePointer(event)
if (isDragging.value) {
void handleLayoutDrag(event)
}
}
const handlePointerUp = (event: PointerEvent) => {
if (isDragging.value) {
isDragging.value = false
void endDrag(event)
// Clear Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = false
}
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Emit node-click for selection handling in GraphCanvas
const dx = event.clientX - lastX.value
const dy = event.clientY - lastY.value
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
emit('node-click', event, nodeData, wasDragging)
}
const handleCollapse = () => {
isCollapsed.value = !isCollapsed.value
// Emit event so parent can sync with LiteGraph if needed
@@ -453,14 +383,36 @@ const handleTitleUpdate = (newTitle: string) => {
emit('update:title', nodeData.id, newTitle)
}
const handleEnterSubgraph = () => {
const graph = app.graph?.rootGraph || app.graph
if (!graph) {
console.warn('LGraphNode: No graph available for subgraph navigation')
return
}
const locatorId = getLocatorIdFromNodeData(nodeData)
const litegraphNode = getNodeByLocatorId(graph, locatorId)
if (!litegraphNode?.isSubgraphNode() || !('subgraph' in litegraphNode)) {
console.warn('LGraphNode: Node is not a valid subgraph node', litegraphNode)
return
}
const canvas = app.canvas
if (!canvas || typeof canvas.openSubgraph !== 'function') {
console.warn('LGraphNode: Canvas or openSubgraph method not available')
return
}
canvas.openSubgraph(litegraphNode.subgraph)
}
const nodeOutputs = useNodeOutputStore()
const nodeImageUrls = ref<string[]>([])
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
// Construct proper locator ID using subgraph ID from VueNodeData
const locatorId = nodeData.subgraphId
? `${nodeData.subgraphId}:${nodeData.id}`
: nodeData.id
const locatorId = getLocatorIdFromNodeData(nodeData)
// Use root graph for getNodeByLocatorId since it needs to traverse from root
const rootGraph = app.graph?.rootGraph || app.graph
@@ -493,11 +445,4 @@ watch(
},
{ deep: true }
)
// Template ref for tooltip positioning
const nodeContainerRef = ref<HTMLElement>()
// Provide nodeImageUrls and tooltip container to child components
provide('nodeImageUrls', nodeImageUrls)
provide('tooltipContainer', nodeContainerRef)
</script>

View File

@@ -4,7 +4,7 @@
</div>
<div
v-else
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move"
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move w-full"
:data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick"
>
@@ -36,17 +36,39 @@
@cancel="handleTitleCancel"
/>
</div>
<!-- Title Buttons -->
<div v-if="!readonly" class="flex items-center">
<IconButton
v-if="isSubgraphNode"
size="sm"
type="transparent"
class="text-stone-200 dark-theme:text-slate-300"
data-testid="subgraph-enter-button"
title="Enter Subgraph"
@click.stop="handleEnterSubgraph"
@dblclick.stop
>
<i class="pi pi-external-link"></i>
</IconButton>
</div>
</div>
</template>
<script setup lang="ts">
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { app } from '@/scripts/app'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
interface NodeHeaderProps {
nodeData?: VueNodeData
@@ -60,6 +82,7 @@ const { nodeData, readonly, collapsed } = defineProps<NodeHeaderProps>()
const emit = defineEmits<{
collapse: []
'update:title': [newTitle: string]
'enter-subgraph': []
}>()
// Error boundary implementation
@@ -111,6 +134,22 @@ watch(
}
)
// Subgraph detection
const isSubgraphNode = computed(() => {
if (!nodeData?.id) return false
// Get the underlying LiteGraph node
const graph = app.graph?.rootGraph || app.graph
if (!graph) return false
const locatorId = getLocatorIdFromNodeData(nodeData)
const litegraphNode = getNodeByLocatorId(graph, locatorId)
// Use the official type guard method
return litegraphNode?.isSubgraphNode() ?? false
})
// Event handlers
const handleCollapse = () => {
emit('collapse')
@@ -134,4 +173,8 @@ const handleTitleEdit = (newTitle: string) => {
const handleTitleCancel = () => {
isEditing.value = false
}
const handleEnterSubgraph = () => {
emit('enter-subgraph')
}
</script>

View File

@@ -8,19 +8,17 @@
* - Layout mutations for visual feedback
* - Integration with LiteGraph canvas selection system
*/
import type { Ref } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
interface NodeManager {
getNode: (id: string) => any
}
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
function useNodeEventHandlersIndividual() {
const canvasStore = useCanvasStore()
const { nodeManager } = useVueNodeLifecycle()
const { bringNodeToFront } = useNodeZIndex()
const { shouldHandleNodePointerEvents } = useCanvasInteractions()
@@ -237,3 +235,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
deselectNodes
}
}
export const useNodeEventHandlers = createSharedComposable(
useNodeEventHandlersIndividual
)

View File

@@ -0,0 +1,93 @@
import { type MaybeRefOrGetter, computed, ref, toValue } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
// Treat tiny pointer jitter as a click, not a drag
const DRAG_THRESHOLD_PX = 4
export function useNodePointerInteractions(
nodeDataMaybe: MaybeRefOrGetter<VueNodeData>,
onPointerUp: (
event: PointerEvent,
nodeData: VueNodeData,
wasDragging: boolean
) => void
) {
const nodeData = toValue(nodeDataMaybe)
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeData.id)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
useCanvasInteractions()
// Drag state for styling
const isDragging = ref(false)
const dragStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
const lastX = ref(0)
const lastY = ref(0)
const handlePointerDown = (event: PointerEvent) => {
if (!nodeData) {
console.warn(
'LGraphNode: nodeData is null/undefined in handlePointerDown'
)
return
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Start drag using layout system
isDragging.value = true
// Set Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = true
startDrag(event)
lastY.value = event.clientY
lastX.value = event.clientX
}
const handlePointerMove = (event: PointerEvent) => {
if (isDragging.value) {
void handleDrag(event)
}
}
const handlePointerUp = (event: PointerEvent) => {
if (isDragging.value) {
isDragging.value = false
void endDrag(event)
// Clear Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = false
}
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Emit node-click for selection handling in GraphCanvas
const dx = event.clientX - lastX.value
const dy = event.clientY - lastY.value
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
onPointerUp(event, nodeData, wasDragging)
}
return {
isDragging,
dragStyle,
handlePointerMove,
handlePointerDown,
handlePointerUp
}
}

View File

@@ -1,3 +1,4 @@
import { storeToRefs } from 'pinia'
/**
* Composable for individual Vue node components
*
@@ -6,7 +7,7 @@
*/
import { computed, inject } from 'vue'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -17,14 +18,14 @@ import { LayoutSource, type Point } from '@/renderer/core/layout/types'
* Uses customRef for shared write access with Canvas renderer
*/
export function useNodeLayout(nodeId: string) {
const store = layoutStore
const mutations = useLayoutMutations()
const { selectedNodeIds } = storeToRefs(useCanvasStore())
// Get transform utilities from TransformPane if available
const transformState = inject(TransformStateKey)
// Get the customRef for this node (shared write access)
const layoutRef = store.getNodeLayoutRef(nodeId)
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
// Computed properties for easy access
const position = computed(() => {
@@ -53,8 +54,6 @@ export function useNodeLayout(nodeId: string) {
let dragStartMouse: Point | null = null
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
const selectedNodeIds = inject(SelectedNodeIdsKey, null)
/**
* Start dragging the node
*/

View File

@@ -3,10 +3,9 @@ import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
@@ -73,11 +72,29 @@ const addComboWidget = (
const currentValue = getDefaultValue(inputSpec)
const displayLabel = currentValue ?? t('widgets.selectModel')
const widget = node.addWidget('asset', inputSpec.name, displayLabel, () => {
console.log(
`Asset Browser would open here for:\nNode: ${node.type}\nWidget: ${inputSpec.name}\nCurrent Value:${currentValue}`
)
})
const assetBrowserDialog = useAssetBrowserDialog()
const widget = node.addWidget(
'asset',
inputSpec.name,
displayLabel,
async () => {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (filename: string) => {
const oldValue = widget.value
widget.value = filename
// Using onWidgetChanged prevents a callback race where asset selection could reopen the dialog
node.onWidgetChanged?.(widget.name, filename, oldValue, widget)
}
})
}
)
return widget
}
@@ -96,11 +113,14 @@ const addComboWidget = (
)
if (inputSpec.remote) {
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
widget: widget as IComboWidget
widget
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
@@ -116,16 +136,19 @@ const addComboWidget = (
}
if (inputSpec.control_after_generate) {
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
widget.linkedWidgets = addValueControlWidgets(
node,
widget as IComboWidget,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget as IBaseWidget
return widget
}
export const useComboWidget = () => {

View File

@@ -33,12 +33,43 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
)
})
/** Internal computed for efficient reverse lookup: nodeType -> category */
const nodeTypeToCategory = computed(() => {
const lookup: Record<string, string> = {}
for (const [category, providers] of Object.entries(modelToNodeMap.value)) {
for (const provider of providers) {
// Only store the first category for each node type (matches current assetService behavior)
if (!lookup[provider.nodeDef.name]) {
lookup[provider.nodeDef.name] = category
}
}
}
return lookup
})
/** Get set of all registered node types for efficient lookup */
function getRegisteredNodeTypes(): Set<string> {
registerDefaults()
return registeredNodeTypes.value
}
/**
* Get the category for a given node type.
* Performs efficient O(1) lookup using cached reverse map.
* @param nodeType The node type name to find the category for
* @returns The category name, or undefined if not found
*/
function getCategoryForNodeType(nodeType: string): string | undefined {
registerDefaults()
// Handle invalid input gracefully
if (!nodeType || typeof nodeType !== 'string') {
return undefined
}
return nodeTypeToCategory.value[nodeType]
}
/**
* Get the node provider for the given model type name.
* @param modelType The name of the model type to get the node provider for.
@@ -109,6 +140,7 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
return {
modelToNodeMap,
getRegisteredNodeTypes,
getCategoryForNodeType,
getNodeProvider,
getAllNodeProviders,
registerNodeProvider,

View File

@@ -364,39 +364,6 @@ export const downloadUrlToHfRepoUrl = (url: string): string => {
}
}
export const isSemVer = (
version: string
): version is `${number}.${number}.${number}` => {
const regex = /^\d+\.\d+\.\d+$/
return regex.test(version)
}
const normalizeVersion = (version: string) =>
version
.split(/[+.-]/)
.map(Number)
.filter((part) => !Number.isNaN(part))
export function compareVersions(
versionA: string | undefined,
versionB: string | undefined
): number {
versionA ??= '0.0.0'
versionB ??= '0.0.0'
const aParts = normalizeVersion(versionA)
const bParts = normalizeVersion(versionB)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] ?? 0
const bPart = bParts[i] ?? 0
if (aPart < bPart) return -1
if (aPart > bPart) return 1
}
return 0
}
/**
* Converts Metronome's integer amount back to a formatted currency string.
* For USD, converts from cents to dollars.

View File

@@ -8,6 +8,23 @@ import { parseNodeLocatorId } from '@/types/nodeIdentification'
import { isSubgraphIoNode } from './typeGuardUtil'
interface NodeWithId {
id: string | number
subgraphId?: string | null
}
/**
* Constructs a locator ID from node data with optional subgraph context.
*
* @param nodeData - Node data containing id and optional subgraphId
* @returns The locator ID string
*/
export function getLocatorIdFromNodeData(nodeData: NodeWithId): string {
return nodeData.subgraphId
? `${nodeData.subgraphId}:${String(nodeData.id)}`
: String(nodeData.id)
}
/**
* Parses an execution ID into its component parts.
*

View File

@@ -1,4 +1,4 @@
import * as semver from 'semver'
import { clean, satisfies } from 'semver'
import type {
ConflictDetail,
@@ -11,7 +11,7 @@ import type {
* @returns Cleaned version string or original if cleaning fails
*/
export function cleanVersion(version: string): string {
return semver.clean(version) || version
return clean(version) || version
}
/**
@@ -23,7 +23,7 @@ export function cleanVersion(version: string): string {
export function satisfiesVersion(version: string, range: string): boolean {
try {
const cleanedVersion = cleanVersion(version)
return semver.satisfies(cleanedVersion, range)
return satisfies(cleanedVersion, range)
} catch {
return false
}

View File

@@ -33,6 +33,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { runWhenGlobalIdle } from '@/base/common/async'
import MenuHamburger from '@/components/MenuHamburger.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
@@ -253,33 +254,30 @@ void nextTick(() => {
})
const onGraphReady = () => {
requestIdleCallback(
() => {
// Setting values now available after comfyApp.setup.
// Load keybindings.
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()
runWhenGlobalIdle(() => {
// Setting values now available after comfyApp.setup.
// Load keybindings.
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()
// Load server config
wrapWithErrorHandling(useServerConfigStore().loadServerConfig)(
SERVER_CONFIG_ITEMS,
settingStore.get('Comfy.Server.ServerConfigValues')
)
// Load server config
wrapWithErrorHandling(useServerConfigStore().loadServerConfig)(
SERVER_CONFIG_ITEMS,
settingStore.get('Comfy.Server.ServerConfigValues')
)
// Load model folders
void wrapWithErrorHandlingAsync(useModelStore().loadModelFolders)()
// Load model folders
void wrapWithErrorHandlingAsync(useModelStore().loadModelFolders)()
// Non-blocking load of node frequencies
void wrapWithErrorHandlingAsync(
useNodeFrequencyStore().loadNodeFrequencies
)()
// Non-blocking load of node frequencies
void wrapWithErrorHandlingAsync(
useNodeFrequencyStore().loadNodeFrequencies
)()
// Node defs now available after comfyApp.setup.
// Explicitly initialize nodeSearchService to avoid indexing delay when
// node search is triggered
useNodeDefStore().nodeSearchService.searchNode('')
},
{ timeout: 1000 }
)
// Node defs now available after comfyApp.setup.
// Explicitly initialize nodeSearchService to avoid indexing delay when
// node search is triggered
useNodeDefStore().nodeSearchService.searchNode('')
}, 1000)
}
</script>

View File

@@ -43,11 +43,11 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { valid as validSemver } from 'semver'
import { computed, ref, watch } from 'vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import type { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
import PackVersionSelectorPopover from '@/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
@@ -81,7 +81,9 @@ const installedVersion = computed(() => {
'nightly'
// If Git hash, truncate to 7 characters
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)
return validSemver(version)
? version
: version.slice(0, TRUNCATED_HASH_LENGTH)
})
const toggleVersionSelector = (event: Event) => {

View File

@@ -84,6 +84,7 @@ import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { valid as validSemver } from 'semver'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -94,7 +95,6 @@ import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import type { components } from '@/types/comfyRegistryTypes'
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
import { isSemVer } from '@/utils/formatUtil'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
@@ -142,7 +142,7 @@ onMounted(() => {
getInitialSelectedVersion() ?? SelectedVersionValues.LATEST
selectedVersion.value =
// Use NIGHTLY when version is a Git hash
isSemVer(initialVersion) ? initialVersion : SelectedVersionValues.NIGHTLY
validSemver(initialVersion) ? initialVersion : SelectedVersionValues.NIGHTLY
})
const getInitialSelectedVersion = () => {

View File

@@ -1,10 +1,33 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetDetails: vi.fn()
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'assetBrowser.allModels': 'All Models',
'assetBrowser.assets': 'Assets',
'assetBrowser.unknown': 'unknown'
}
return translations[key] || key
},
d: (date: Date) => date.toLocaleDateString()
}))
describe('useAssetBrowser', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
// Test fixtures - minimal data focused on functionality being tested
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
id: 'test-id',
@@ -26,8 +49,8 @@ describe('useAssetBrowser', () => {
user_metadata: { description: 'Test model' }
})
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
const result = transformAssetForDisplay(apiAsset)
const { filteredAssets } = useAssetBrowser([apiAsset])
const result = filteredAssets.value[0] // Get the transformed asset from filteredAssets
// Preserves API properties
expect(result.id).toBe(apiAsset.id)
@@ -49,15 +72,13 @@ describe('useAssetBrowser', () => {
user_metadata: undefined
})
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
const result = transformAssetForDisplay(apiAsset)
const { filteredAssets } = useAssetBrowser([apiAsset])
const result = filteredAssets.value[0]
expect(result.description).toBe('loras model')
})
it('formats various file sizes correctly', () => {
const { transformAssetForDisplay } = useAssetBrowser([])
const testCases = [
{ size: 512, expected: '512 B' },
{ size: 1536, expected: '1.5 KB' },
@@ -67,7 +88,8 @@ describe('useAssetBrowser', () => {
testCases.forEach(({ size, expected }) => {
const asset = createApiAsset({ size })
const result = transformAssetForDisplay(asset)
const { filteredAssets } = useAssetBrowser([asset])
const result = filteredAssets.value[0]
expect(result.formattedSize).toBe(expected)
})
})
@@ -236,18 +258,182 @@ describe('useAssetBrowser', () => {
})
})
describe('Asset Selection', () => {
it('returns selected asset UUID for efficient handling', () => {
describe('Async Asset Selection with Detail Fetching', () => {
it('should fetch asset details and call onSelect with filename when provided', async () => {
const onSelectSpy = vi.fn()
const asset = createApiAsset({
id: 'test-uuid-123',
name: 'selected_model.safetensors'
id: 'asset-123',
name: 'test-model.safetensors'
})
const { selectAsset, transformAssetForDisplay } = useAssetBrowser([asset])
const displayAsset = transformAssetForDisplay(asset)
const result = selectAsset(displayAsset)
const detailAsset = createApiAsset({
id: 'asset-123',
name: 'test-model.safetensors',
user_metadata: { filename: 'checkpoints/test-model.safetensors' }
})
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
expect(result).toBe('test-uuid-123')
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id, onSelectSpy)
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-123')
expect(onSelectSpy).toHaveBeenCalledWith(
'checkpoints/test-model.safetensors'
)
})
it('should handle missing user_metadata.filename as error', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const asset = createApiAsset({ id: 'asset-456' })
const detailAsset = createApiAsset({
id: 'asset-456',
user_metadata: { filename: '' } // Invalid empty filename
})
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id, onSelectSpy)
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-456')
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid asset filename:',
expect.arrayContaining([
expect.objectContaining({
message: 'Filename cannot be empty'
})
]),
'for asset:',
'asset-456'
)
})
it('should handle API errors gracefully', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const asset = createApiAsset({ id: 'asset-789' })
const apiError = new Error('API Error')
vi.mocked(assetService.getAssetDetails).mockRejectedValue(apiError)
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id, onSelectSpy)
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-789')
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch asset details for asset-789'),
apiError
)
})
it('should not fetch details when no callback provided', async () => {
const asset = createApiAsset({ id: 'asset-no-callback' })
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id)
expect(assetService.getAssetDetails).not.toHaveBeenCalled()
})
})
describe('Filename Validation Security', () => {
const createValidationTest = (filename: string) => {
const testAsset = createApiAsset({ id: 'validation-test' })
const detailAsset = createApiAsset({
id: 'validation-test',
user_metadata: { filename }
})
return { testAsset, detailAsset }
}
it('accepts valid file paths with forward slashes', async () => {
const onSelectSpy = vi.fn()
const { testAsset, detailAsset } = createValidationTest(
'models/checkpoints/v1/test-model.safetensors'
)
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
await selectAssetWithCallback(testAsset.id, onSelectSpy)
expect(onSelectSpy).toHaveBeenCalledWith(
'models/checkpoints/v1/test-model.safetensors'
)
})
it('rejects directory traversal attacks', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const maliciousPaths = [
'../malicious-model.safetensors',
'models/../../../etc/passwd',
'/etc/passwd'
]
for (const path of maliciousPaths) {
const { testAsset, detailAsset } = createValidationTest(path)
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
await selectAssetWithCallback(testAsset.id, onSelectSpy)
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid asset filename:',
expect.arrayContaining([
expect.objectContaining({
message: 'Path must not start with / or contain ..'
})
]),
'for asset:',
'validation-test'
)
}
})
it('rejects invalid filename characters', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const invalidChars = ['\\', ':', '*', '?', '"', '<', '>', '|']
for (const char of invalidChars) {
const { testAsset, detailAsset } = createValidationTest(
`bad${char}filename.safetensors`
)
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
await selectAssetWithCallback(testAsset.id, onSelectSpy)
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid asset filename:',
expect.arrayContaining([
expect.objectContaining({
message: 'Invalid filename characters'
})
]),
'for asset:',
'validation-test'
)
}
})
})

View File

@@ -6,11 +6,18 @@ import { useDialogStore } from '@/stores/dialogStore'
// Mock the dialog store
vi.mock('@/stores/dialogStore')
// Mock the asset service
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetsForNodeType: vi.fn().mockResolvedValue([])
}
}))
// Test factory functions
interface AssetBrowserProps {
nodeType: string
inputName: string
onAssetSelected?: ReturnType<typeof vi.fn>
onAssetSelected?: (filename: string) => void
}
function createAssetBrowserProps(
@@ -25,7 +32,7 @@ function createAssetBrowserProps(
describe('useAssetBrowserDialog', () => {
describe('Asset Selection Flow', () => {
it('auto-closes dialog when asset is selected', () => {
it('auto-closes dialog when asset is selected', async () => {
// Create fresh mocks for this test
const mockShowDialog = vi.fn()
const mockCloseDialog = vi.fn()
@@ -41,7 +48,7 @@ describe('useAssetBrowserDialog', () => {
const onAssetSelected = vi.fn()
const props = createAssetBrowserProps({ onAssetSelected })
assetBrowserDialog.show(props)
await assetBrowserDialog.show(props)
// Get the onSelect handler that was passed to the dialog
const dialogCall = mockShowDialog.mock.calls[0][0]
@@ -50,14 +57,14 @@ describe('useAssetBrowserDialog', () => {
// Simulate asset selection
onSelectHandler('selected-asset-path')
// Should call the original callback and close dialog
// Should call the original callback and trigger hide animation
expect(onAssetSelected).toHaveBeenCalledWith('selected-asset-path')
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'global-asset-browser'
})
})
it('closes dialog when close handler is called', () => {
it('closes dialog when close handler is called', async () => {
// Create fresh mocks for this test
const mockShowDialog = vi.fn()
const mockCloseDialog = vi.fn()
@@ -72,7 +79,7 @@ describe('useAssetBrowserDialog', () => {
const assetBrowserDialog = useAssetBrowserDialog()
const props = createAssetBrowserProps()
assetBrowserDialog.show(props)
await assetBrowserDialog.show(props)
// Get the onClose handler that was passed to the dialog
const dialogCall = mockShowDialog.mock.calls[0][0]

View File

@@ -1,10 +1,9 @@
import { compare, valid } from 'semver'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
// Import mocked utils
import { compareVersions, isSemVer } from '@/utils/formatUtil'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
// Mock Vue's onMounted to execute immediately for testing
@@ -25,16 +24,16 @@ vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('@/utils/formatUtil', () => ({
compareVersions: vi.fn(),
isSemVer: vi.fn()
vi.mock('semver', () => ({
compare: vi.fn(),
valid: vi.fn()
}))
const mockUseInstalledPacks = vi.mocked(useInstalledPacks)
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
const mockCompareVersions = vi.mocked(compareVersions)
const mockIsSemVer = vi.mocked(isSemVer)
const mockSemverCompare = vi.mocked(compare)
const mockSemverValid = vi.mocked(valid)
describe('useUpdateAvailableNodes', () => {
const mockInstalledPacks = [
@@ -86,19 +85,19 @@ describe('useUpdateAvailableNodes', () => {
}
})
mockIsSemVer.mockImplementation(
(version: string): version is `${number}.${number}.${number}` => {
return !version.includes('nightly')
}
)
mockSemverValid.mockImplementation((version) => {
return version &&
typeof version === 'string' &&
!version.includes('nightly')
? version
: null
})
mockCompareVersions.mockImplementation(
(latest: string | undefined, installed: string | undefined) => {
if (latest === '2.0.0' && installed === '1.0.0') return 1 // outdated
if (latest === '1.0.0' && installed === '1.0.0') return 0 // up to date
return 0
}
)
mockSemverCompare.mockImplementation((latest, installed) => {
if (latest === '2.0.0' && installed === '1.0.0') return 1 // outdated
if (latest === '1.0.0' && installed === '1.0.0') return 0 // up to date
return 0
})
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled,
@@ -322,10 +321,10 @@ describe('useUpdateAvailableNodes', () => {
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockCompareVersions).toHaveBeenCalledWith('2.0.0', '1.0.0')
expect(mockSemverCompare).toHaveBeenCalledWith('2.0.0', '1.0.0')
})
it('calls isSemVer to check nightly versions', () => {
it('calls semver.valid to check nightly versions', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
isLoading: ref(false),
@@ -338,7 +337,7 @@ describe('useUpdateAvailableNodes', () => {
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockIsSemVer).toHaveBeenCalledWith('nightly-abc123')
expect(mockSemverValid).toHaveBeenCalledWith('nightly-abc123')
})
it('calls isPackInstalled for each pack', () => {

View File

@@ -0,0 +1,174 @@
import { describe, expect, it, vi } from 'vitest'
import { type ComputedRef, computed } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { NodeLayout } from '@/renderer/core/layout/types'
import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory'
// Mock layoutStore
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getAllNodes: vi.fn()
}
}))
// Helper to create mock links that satisfy LGraph['links'] type
function createMockLinks(): LGraph['links'] {
const map = new Map<number, LLink>()
return Object.assign(map, {}) as LGraph['links']
}
describe('MinimapDataSource', () => {
describe('MinimapDataSourceFactory', () => {
it('should create LayoutStoreDataSource when LayoutStore has data', () => {
// Arrange
const mockNodes = new Map<string, NodeLayout>([
[
'node1',
{
id: 'node1',
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
]
])
// Create a computed ref that returns the map
const computedNodes: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => mockNodes)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedNodes)
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
// Assert
expect(dataSource).toBeDefined()
expect(dataSource.hasData()).toBe(true)
expect(dataSource.getNodeCount()).toBe(1)
})
it('should create LiteGraphDataSource when LayoutStore is empty', () => {
// Arrange
const emptyMap = new Map<string, NodeLayout>()
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => emptyMap)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
const mockNode: Pick<
LGraphNode,
'id' | 'pos' | 'size' | 'bgcolor' | 'mode' | 'has_errors' | 'outputs'
> = {
id: 'node1' as NodeId,
pos: [0, 0],
size: [100, 50],
bgcolor: '#fff',
mode: 0,
has_errors: false,
outputs: []
}
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [mockNode as LGraphNode],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
// Assert
expect(dataSource).toBeDefined()
expect(dataSource.hasData()).toBe(true)
expect(dataSource.getNodeCount()).toBe(1)
const nodes = dataSource.getNodes()
expect(nodes).toHaveLength(1)
expect(nodes[0]).toMatchObject({
id: 'node1',
x: 0,
y: 0,
width: 100,
height: 50
})
})
it('should handle empty graph correctly', () => {
// Arrange
const emptyMap = new Map<string, NodeLayout>()
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => emptyMap)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
// Assert
expect(dataSource.hasData()).toBe(false)
expect(dataSource.getNodeCount()).toBe(0)
expect(dataSource.getNodes()).toEqual([])
expect(dataSource.getLinks()).toEqual([])
expect(dataSource.getGroups()).toEqual([])
})
})
describe('Bounds calculation', () => {
it('should calculate correct bounds from nodes', () => {
// Arrange
const emptyMap = new Map<string, NodeLayout>()
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => emptyMap)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
const mockNode1: Pick<LGraphNode, 'id' | 'pos' | 'size' | 'outputs'> = {
id: 'node1' as NodeId,
pos: [0, 0],
size: [100, 50],
outputs: []
}
const mockNode2: Pick<LGraphNode, 'id' | 'pos' | 'size' | 'outputs'> = {
id: 'node2' as NodeId,
pos: [200, 100],
size: [150, 75],
outputs: []
}
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [mockNode1 as LGraphNode, mockNode2 as LGraphNode],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
const bounds = dataSource.getBounds()
// Assert
expect(bounds).toEqual({
minX: 0,
minY: 0,
maxX: 350,
maxY: 175,
width: 350,
height: 175
})
})
})
})

View File

@@ -14,7 +14,7 @@ const createMockCanvasContext = () => ({
const isCI = Boolean(process.env.CI)
const describeIfNotCI = isCI ? describe.skip : describe
describeIfNotCI('Transform Performance', () => {
describeIfNotCI.skip('Transform Performance', () => {
let transformState: ReturnType<typeof useTransformState>
let mockCanvas: any

View File

@@ -1,14 +1,29 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
const mockData = vi.hoisted(() => ({
mockNodeIds: new Set<string>(),
mockExecuting: false
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => {
const getCanvas = vi.fn()
const useCanvasStore = () => ({
getCanvas,
selectedNodeIds: computed(() => mockData.mockNodeIds)
})
return {
useCanvasStore
}
})
vi.mock(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
@@ -47,7 +62,7 @@ vi.mock(
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
() => ({
useNodeExecutionState: vi.fn(() => ({
executing: computed(() => false),
executing: computed(() => mockData.mockExecuting),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
@@ -72,55 +87,44 @@ const i18n = createI18n({
}
}
})
function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
return mount(LGraphNode, {
props,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,
SlotConnectionDot: true
}
}
})
}
const mockNodeData: VueNodeData = {
id: 'test-node-123',
title: 'Test Node',
type: 'TestNode',
mode: 0,
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
describe('LGraphNode', () => {
const mockNodeData: VueNodeData = {
id: 'test-node-123',
title: 'Test Node',
type: 'TestNode',
mode: 0,
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
const mountLGraphNode = (props: any, selectedNodeIds = new Set()) => {
return mount(LGraphNode, {
props,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
provide: {
[SelectedNodeIdsKey as symbol]: ref(selectedNodeIds)
},
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,
SlotConnectionDot: true
}
}
})
}
beforeEach(() => {
vi.clearAllMocks()
// Reset to default mock
vi.mocked(useNodeExecutionState).mockReturnValue({
executing: computed(() => false),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'idle' as const)
})
vi.resetAllMocks()
mockData.mockNodeIds = new Set()
mockData.mockExecuting = false
})
it('should call resize tracking composable with node ID', () => {
@@ -146,9 +150,6 @@ describe('LGraphNode', () => {
}),
i18n
],
provide: {
[SelectedNodeIdsKey as symbol]: ref(new Set())
},
stubs: {
NodeSlots: true,
NodeWidgets: true,
@@ -162,24 +163,15 @@ describe('LGraphNode', () => {
})
it('should apply selected styling when selected prop is true', () => {
const wrapper = mountLGraphNode(
{ nodeData: mockNodeData, selected: true },
new Set(['test-node-123'])
)
mockData.mockNodeIds = new Set(['test-node-123'])
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('outline-2')
expect(wrapper.classes()).toContain('outline-black')
expect(wrapper.classes()).toContain('dark-theme:outline-white')
})
it('should apply executing animation when executing prop is true', () => {
// Mock the execution state to return executing: true
vi.mocked(useNodeExecutionState).mockReturnValue({
executing: computed(() => true),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'running' as const)
})
mockData.mockExecuting = true
const wrapper = mountLGraphNode({ nodeData: mockNodeData })

View File

@@ -0,0 +1,223 @@
/**
* Tests for NodeHeader subgraph functionality
*/
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
// Mock dependencies
vi.mock('@/scripts/app', () => ({
app: {
graph: null as any
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByLocatorId: vi.fn(),
getLocatorIdFromNodeData: vi.fn((nodeData) =>
nodeData.subgraphId
? `${nodeData.subgraphId}:${String(nodeData.id)}`
: String(nodeData.id)
)
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: vi.fn()
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key) => key)
}),
createI18n: vi.fn(() => ({
global: {
t: vi.fn((key) => key)
}
}))
}))
vi.mock('@/i18n', () => ({
st: vi.fn((key) => key),
t: vi.fn((key) => key),
i18n: {
global: {
t: vi.fn((key) => key)
}
}
}))
describe('NodeHeader - Subgraph Functionality', () => {
// Helper to setup common mocks
const setupMocks = async (isSubgraph = true, hasGraph = true) => {
const { app } = await import('@/scripts/app')
if (hasGraph) {
;(app as any).graph = { rootGraph: {} }
} else {
;(app as any).graph = null
}
vi.mocked(getNodeByLocatorId).mockReturnValue({
isSubgraphNode: () => isSubgraph
} as any)
}
beforeEach(() => {
vi.clearAllMocks()
})
const createMockNodeData = (
id: string,
subgraphId?: string
): VueNodeData => ({
id,
title: 'Test Node',
type: 'TestNode',
mode: 0,
selected: false,
executing: false,
subgraphId,
widgets: [],
inputs: [],
outputs: [],
hasErrors: false,
flags: {}
})
const createWrapper = (props = {}) => {
return mount(NodeHeader, {
props,
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: vi.fn((key: string) => key),
$primevue: { config: {} }
}
}
})
}
it('should show subgraph button for subgraph nodes', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(true)
})
it('should not show subgraph button for regular nodes', async () => {
await setupMocks(false) // isSubgraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should not show subgraph button in readonly mode', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: true
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should emit enter-subgraph event when button is clicked', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
await subgraphButton.trigger('click')
expect(wrapper.emitted('enter-subgraph')).toBeTruthy()
expect(wrapper.emitted('enter-subgraph')).toHaveLength(1)
})
it('should handle subgraph context correctly', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1', 'subgraph-id'),
readonly: false
})
await wrapper.vm.$nextTick()
// Should call getNodeByLocatorId with correct locator ID
expect(vi.mocked(getNodeByLocatorId)).toHaveBeenCalledWith(
expect.anything(),
'subgraph-id:test-node-1'
)
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(true)
})
it('should handle missing graph gracefully', async () => {
await setupMocks(true, false) // isSubgraph = true, hasGraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should prevent event propagation on double click', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
// Mock event object
const mockEvent = {
stopPropagation: vi.fn()
}
// Trigger dblclick event
await subgraphButton.trigger('dblclick', mockEvent)
// Should prevent propagation (handled by @dblclick.stop directive)
// This is tested by ensuring the component doesn't error and renders correctly
expect(subgraphButton.exists()).toBe(true)
})
})

View File

@@ -1,98 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { computed, shallowRef } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
type GraphNodeManager,
type VueNodeData,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn()
}))
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
useLayoutMutations: vi.fn()
}))
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
useGraphNodeManager: vi.fn()
}))
function createMockCanvas(): Pick<
LGraphCanvas,
'select' | 'deselect' | 'deselectAll'
> {
return {
vi.mock('@/renderer/core/canvas/canvasStore', () => {
const canvas: Partial<LGraphCanvas> = {
select: vi.fn(),
deselect: vi.fn(),
deselectAll: vi.fn()
}
}
function createMockNode(): Pick<LGraphNode, 'id' | 'selected' | 'flags'> {
const updateSelectedItems = vi.fn()
return {
useCanvasStore: vi.fn(() => ({
canvas: canvas as LGraphCanvas,
updateSelectedItems,
selectedItems: []
}))
}
})
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
shouldHandleNodePointerEvents: computed(() => true) // Default to allowing pointer events
}))
}))
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => {
const setSource = vi.fn()
const bringNodeToFront = vi.fn()
return {
useLayoutMutations: vi.fn(() => ({
setSource,
bringNodeToFront
}))
}
})
vi.mock('@/composables/graph/useGraphNodeManager', () => {
const mockNode = {
id: 'node-1',
selected: false,
flags: { pinned: false }
}
}
function createMockNodeManager(
node: Pick<LGraphNode, 'id' | 'selected' | 'flags'>
) {
const nodeManager = shallowRef({
getNode: vi.fn(() => mockNode as Partial<LGraphNode> as LGraphNode)
} as Partial<GraphNodeManager> as GraphNodeManager)
return {
getNode: vi.fn().mockReturnValue(node) as ReturnType<
typeof useGraphNodeManager
>['getNode']
useGraphNodeManager: vi.fn(() => nodeManager)
}
}
})
function createMockCanvasStore(
canvas: Pick<LGraphCanvas, 'select' | 'deselect' | 'deselectAll'>
): Pick<
ReturnType<typeof useCanvasStore>,
'canvas' | 'selectedItems' | 'updateSelectedItems'
> {
vi.mock('@/composables/graph/useVueNodeLifecycle', () => {
const nodeManager = useGraphNodeManager(undefined as unknown as LGraph)
return {
canvas: canvas as LGraphCanvas,
selectedItems: [],
updateSelectedItems: vi.fn()
useVueNodeLifecycle: vi.fn(() => ({
nodeManager
}))
}
}
function createMockLayoutMutations(): Pick<
ReturnType<typeof useLayoutMutations>,
'setSource' | 'bringNodeToFront'
> {
return {
setSource: vi.fn(),
bringNodeToFront: vi.fn()
}
}
function createMockCanvasInteractions(): Pick<
ReturnType<typeof useCanvasInteractions>,
'shouldHandleNodePointerEvents'
> {
return {
shouldHandleNodePointerEvents: computed(() => true) // Default to allowing pointer events
}
}
})
describe('useNodeEventHandlers', () => {
let mockCanvas: ReturnType<typeof createMockCanvas>
let mockNode: ReturnType<typeof createMockNode>
let mockNodeManager: ReturnType<typeof createMockNodeManager>
let mockCanvasStore: ReturnType<typeof createMockCanvasStore>
let mockLayoutMutations: ReturnType<typeof createMockLayoutMutations>
let mockCanvasInteractions: ReturnType<typeof createMockCanvasInteractions>
const { nodeManager: mockNodeManager } = useVueNodeLifecycle()
const mockNode = mockNodeManager.value!.getNode('fake_id')
const mockLayoutMutations = useLayoutMutations()
const testNodeData: VueNodeData = {
id: 'node-1',
@@ -104,28 +88,13 @@ describe('useNodeEventHandlers', () => {
}
beforeEach(async () => {
mockNode = createMockNode()
mockCanvas = createMockCanvas()
mockNodeManager = createMockNodeManager(mockNode)
mockCanvasStore = createMockCanvasStore(mockCanvas)
mockLayoutMutations = createMockLayoutMutations()
mockCanvasInteractions = createMockCanvasInteractions()
vi.mocked(useCanvasStore).mockReturnValue(
mockCanvasStore as ReturnType<typeof useCanvasStore>
)
vi.mocked(useLayoutMutations).mockReturnValue(
mockLayoutMutations as ReturnType<typeof useLayoutMutations>
)
vi.mocked(useCanvasInteractions).mockReturnValue(
mockCanvasInteractions as ReturnType<typeof useCanvasInteractions>
)
vi.restoreAllMocks()
})
describe('handleNodeSelect', () => {
it('should select single node on regular click', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
const event = new PointerEvent('pointerdown', {
bubbles: true,
@@ -135,17 +104,17 @@ describe('useNodeEventHandlers', () => {
handleNodeSelect(event, testNodeData, false)
expect(mockCanvas.deselectAll).toHaveBeenCalledOnce()
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
expect(mockCanvasStore.updateSelectedItems).toHaveBeenCalledOnce()
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalledOnce()
})
it('should toggle selection on ctrl+click', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
// Test selecting unselected node with ctrl
mockNode.selected = false
mockNode!.selected = false
const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
@@ -155,16 +124,16 @@ describe('useNodeEventHandlers', () => {
handleNodeSelect(ctrlClickEvent, testNodeData, false)
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
})
it('should deselect on ctrl+click of selected node', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
// Test deselecting selected node with ctrl
mockNode.selected = true
mockNode!.selected = true
const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
@@ -174,15 +143,15 @@ describe('useNodeEventHandlers', () => {
handleNodeSelect(ctrlClickEvent, testNodeData, false)
expect(mockCanvas.deselect).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.select).not.toHaveBeenCalled()
expect(canvas?.deselect).toHaveBeenCalledWith(mockNode)
expect(canvas?.select).not.toHaveBeenCalled()
})
it('should handle meta key (Cmd) on Mac', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode.selected = false
mockNode!.selected = false
const metaClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
@@ -192,15 +161,14 @@ describe('useNodeEventHandlers', () => {
handleNodeSelect(metaClickEvent, testNodeData, false)
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
})
it('should bring node to front when not pinned', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
const { handleNodeSelect } = useNodeEventHandlers()
mockNode.flags.pinned = false
mockNode!.flags.pinned = false
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData, false)
@@ -211,49 +179,14 @@ describe('useNodeEventHandlers', () => {
})
it('should not bring pinned node to front', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
const { handleNodeSelect } = useNodeEventHandlers()
mockNode.flags.pinned = true
mockNode!.flags.pinned = true
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData, false)
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
})
it('should handle missing canvas gracefully', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
mockCanvasStore.canvas = null
const event = new PointerEvent('pointerdown')
expect(() => {
handleNodeSelect(event, testNodeData, false)
}).not.toThrow()
expect(mockCanvas.select).not.toHaveBeenCalled()
})
it('should handle missing node gracefully', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
vi.mocked(mockNodeManager.getNode).mockReturnValue(undefined)
const event = new PointerEvent('pointerdown')
const nodeData = {
id: 'missing-node',
title: 'Missing Node',
type: 'test'
} as any
expect(() => {
handleNodeSelect(event, nodeData, false)
}).not.toThrow()
expect(mockCanvas.select).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { assetService } from '@/platform/assets/services/assetService'
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -29,13 +30,25 @@ vi.mock('@/platform/assets/services/assetService', () => ({
}
}))
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
const mockAssetBrowserDialogShow = vi.fn()
return {
useAssetBrowserDialog: vi.fn(() => ({
show: mockAssetBrowserDialogShow
}))
}
})
// Test factory functions
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
const mockCallback = vi.fn()
return {
type: 'combo',
options: {},
name: 'testWidget',
value: undefined,
callback: mockCallback,
y: 0,
...overrides
} as IBaseWidget
}
@@ -45,7 +58,16 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode {
node.comfyClass = comfyClass
// Spy on the addWidget method
vi.spyOn(node, 'addWidget').mockReturnValue(createMockWidget())
vi.spyOn(node, 'addWidget').mockImplementation(
(type, name, value, callback) => {
const widget = createMockWidget({ type, name, value })
// Store the callback function on the widget for testing
if (typeof callback === 'function') {
widget.callback = callback
}
return widget
}
)
return node
}
@@ -61,9 +83,9 @@ function createMockInputSpec(overrides: Partial<InputSpec> = {}): InputSpec {
describe('useComboWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to defaults
mockSettingStoreGet.mockReturnValue(false)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
vi.mocked(useAssetBrowserDialog).mockClear()
})
it('should handle undefined spec', () => {

View File

@@ -4,6 +4,8 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: vi.fn(() => ({
getRegisteredNodeTypes: vi.fn(
@@ -14,7 +16,13 @@ vi.mock('@/stores/modelToNodeStore', () => ({
'VAELoader',
'TestNode'
])
)
),
getCategoryForNodeType: mockGetCategoryForNodeType,
modelToNodeMap: {
checkpoints: [{ nodeDef: { name: 'CheckpointLoaderSimple' } }],
loras: [{ nodeDef: { name: 'LoraLoader' } }],
vae: [{ nodeDef: { name: 'VAELoader' } }]
}
}))
}))
@@ -210,4 +218,87 @@ describe('assetService', () => {
).toBe(false)
})
})
describe('getAssetsForNodeType', () => {
beforeEach(() => {
mockGetCategoryForNodeType.mockClear()
})
it('should return empty array for unregistered node types', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
const result = await assetService.getAssetsForNodeType('UnknownNode')
expect(mockGetCategoryForNodeType).toHaveBeenCalledWith('UnknownNode')
expect(result).toEqual([])
})
it('should use getCategoryForNodeType for efficient category lookup', async () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
const testAssets = [MOCK_ASSETS.checkpoints]
mockApiResponse(testAssets)
const result = await assetService.getAssetsForNodeType(
'CheckpointLoaderSimple'
)
expect(mockGetCategoryForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(result).toEqual(testAssets)
// Verify API call includes correct category
expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models,checkpoints'
)
})
it('should return empty array when no category found', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
const result = await assetService.getAssetsForNodeType('TestNode')
expect(result).toEqual([])
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('should handle API errors gracefully', async () => {
mockGetCategoryForNodeType.mockReturnValue('loras')
mockApiError(500, 'Internal Server Error')
await expect(
assetService.getAssetsForNodeType('LoraLoader')
).rejects.toThrow(
'Unable to load assets for LoraLoader: Server returned 500. Please try again.'
)
})
it('should return all assets without filtering for different categories', async () => {
// Test checkpoints
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
const checkpointAssets = [MOCK_ASSETS.checkpoints]
mockApiResponse(checkpointAssets)
let result = await assetService.getAssetsForNodeType(
'CheckpointLoaderSimple'
)
expect(result).toEqual(checkpointAssets)
// Test loras
mockGetCategoryForNodeType.mockReturnValue('loras')
const loraAssets = [MOCK_ASSETS.loras]
mockApiResponse(loraAssets)
result = await assetService.getAssetsForNodeType('LoraLoader')
expect(result).toEqual(loraAssets)
// Test vae
mockGetCategoryForNodeType.mockReturnValue('vae')
const vaeAssets = [MOCK_ASSETS.vae]
mockApiResponse(vaeAssets)
result = await assetService.getAssetsForNodeType('VAELoader')
expect(result).toEqual(vaeAssets)
})
})
})

View File

@@ -19,7 +19,7 @@ const EXPECTED_DEFAULT_TYPES = [
'gligen'
] as const
type NodeDefStoreType = typeof import('@/stores/nodeDefStore')
type NodeDefStoreType = ReturnType<typeof useNodeDefStore>
// Create minimal but valid ComfyNodeDefImpl for testing
function createMockNodeDef(name: string): ComfyNodeDefImpl {
@@ -343,6 +343,107 @@ describe('useModelToNodeStore', () => {
})
})
describe('getCategoryForNodeType', () => {
it('should return category for known node type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(
modelToNodeStore.getCategoryForNodeType('CheckpointLoaderSimple')
).toBe('checkpoints')
expect(modelToNodeStore.getCategoryForNodeType('LoraLoader')).toBe(
'loras'
)
expect(modelToNodeStore.getCategoryForNodeType('VAELoader')).toBe('vae')
})
it('should return undefined for unknown node type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(
modelToNodeStore.getCategoryForNodeType('NonExistentNode')
).toBeUndefined()
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
})
it('should return first category when node type exists in multiple categories', () => {
const modelToNodeStore = useModelToNodeStore()
// Test with a node that exists in the defaults but add our own first
// Since defaults register 'StyleModelLoader' in 'style_models',
// we verify our custom registrations come after defaults in Object.entries iteration
const result = modelToNodeStore.getCategoryForNodeType('StyleModelLoader')
expect(result).toBe('style_models') // This proves the method works correctly
// Now test that custom registrations after defaults also work
modelToNodeStore.quickRegister(
'unicorn_styles',
'StyleModelLoader',
'param1'
)
const result2 =
modelToNodeStore.getCategoryForNodeType('StyleModelLoader')
// Should still be style_models since it was registered first by defaults
expect(result2).toBe('style_models')
})
it('should trigger lazy registration when called before registerDefaults', () => {
const modelToNodeStore = useModelToNodeStore()
const result = modelToNodeStore.getCategoryForNodeType(
'CheckpointLoaderSimple'
)
expect(result).toBe('checkpoints')
})
it('should be performant for repeated lookups', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
// Measure performance without assuming implementation
const start = performance.now()
for (let i = 0; i < 1000; i++) {
modelToNodeStore.getCategoryForNodeType('CheckpointLoaderSimple')
}
const end = performance.now()
// Should be fast enough for UI responsiveness
expect(end - start).toBeLessThan(10)
})
it('should handle invalid input types gracefully', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
// These should not throw but return undefined
expect(
modelToNodeStore.getCategoryForNodeType(null as any)
).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType(undefined as any)
).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType(123 as any)
).toBeUndefined()
})
it('should be case-sensitive for node type matching', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(
modelToNodeStore.getCategoryForNodeType('checkpointloadersimple')
).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType('CHECKPOINTLOADERSIMPLE')
).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType('CheckpointLoaderSimple')
).toBe('checkpoints')
})
})
describe('edge cases', () => {
it('should handle empty string model type', () => {
const modelToNodeStore = useModelToNodeStore()

View File

@@ -1,10 +1,11 @@
import { createPinia, setActivePinia } from 'pinia'
import { compare as semverCompare } from 'semver'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
// Mock the dependencies
vi.mock('@/utils/formatUtil')
vi.mock('semver')
vi.mock('@/utils/envUtil')
vi.mock('@/platform/updates/common/releaseService')
vi.mock('@/platform/settings/settingStore')
@@ -113,17 +114,15 @@ describe('useReleaseStore', () => {
expect(store.recentReleases).toEqual(releases.slice(0, 3))
})
it('should show update button (shouldShowUpdateButton)', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1) // newer version available
it('should show update button (shouldShowUpdateButton)', () => {
vi.mocked(semverCompare).mockReturnValue(1) // newer version available
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(true)
})
it('should not show update button when no new version', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(-1) // current version is newer
it('should not show update button when no new version', () => {
vi.mocked(semverCompare).mockReturnValue(-1) // current version is newer
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(false)
@@ -131,21 +130,20 @@ describe('useReleaseStore', () => {
})
describe('showVersionUpdates setting', () => {
beforeEach(() => {
beforeEach(async () => {
store.releases = [mockRelease]
})
describe('when notifications are enabled', () => {
beforeEach(() => {
beforeEach(async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
})
it('should show toast for medium/high attention releases', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should show toast for medium/high attention releases', () => {
vi.mocked(semverCompare).mockReturnValue(1)
// Need multiple releases for hasMediumOrHighAttention to work
const mediumRelease = {
@@ -158,17 +156,16 @@ describe('useReleaseStore', () => {
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot for new versions', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should show red dot for new versions', () => {
vi.mocked(semverCompare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
vi.mocked(semverCompare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(true)
})
@@ -181,37 +178,36 @@ describe('useReleaseStore', () => {
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows'
form_factor: 'git-windows',
locale: 'en'
})
})
})
describe('when notifications are disabled', () => {
beforeEach(() => {
beforeEach(async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
})
it('should not show toast even with new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should not show toast even with new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(false)
})
it('should not show red dot even with new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should not show red dot even with new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should not show popup even for latest version', async () => {
it('should not show popup even for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
vi.mocked(semverCompare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(false)
})
@@ -240,7 +236,8 @@ describe('useReleaseStore', () => {
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows'
form_factor: 'git-windows',
locale: 'en'
})
expect(store.releases).toEqual([mockRelease])
})
@@ -254,7 +251,8 @@ describe('useReleaseStore', () => {
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac'
form_factor: 'desktop-mac',
locale: 'en'
})
})
@@ -424,7 +422,7 @@ describe('useReleaseStore', () => {
})
describe('action handlers', () => {
beforeEach(() => {
beforeEach(async () => {
store.releases = [mockRelease]
})
@@ -481,7 +479,7 @@ describe('useReleaseStore', () => {
})
describe('popup visibility', () => {
it('should show toast for medium/high attention releases', async () => {
it('should show toast for medium/high attention releases', () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Release.Version') return null
if (key === 'Comfy.Release.Status') return null
@@ -489,8 +487,7 @@ describe('useReleaseStore', () => {
return null
})
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
vi.mocked(semverCompare).mockReturnValue(1)
const mediumRelease = { ...mockRelease, attention: 'medium' as const }
store.releases = [
@@ -502,9 +499,8 @@ describe('useReleaseStore', () => {
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot for new versions', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should show red dot for new versions', () => {
vi.mocked(semverCompare).mockReturnValue(1)
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
@@ -515,15 +511,14 @@ describe('useReleaseStore', () => {
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0' // Same as release
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0) // versions are equal (latest version)
vi.mocked(semverCompare).mockReturnValue(0) // versions are equal (latest version)
store.releases = [mockRelease]
@@ -565,7 +560,7 @@ describe('useReleaseStore', () => {
})
describe('isElectron environment checks', () => {
beforeEach(() => {
beforeEach(async () => {
// Set up a new version available
store.releases = [mockRelease]
mockSettingStore.get.mockImplementation((key: string) => {
@@ -580,9 +575,8 @@ describe('useReleaseStore', () => {
vi.mocked(isElectron).mockReturnValue(true)
})
it('should show toast when conditions are met', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should show toast when conditions are met', () => {
vi.mocked(semverCompare).mockReturnValue(1)
// Need multiple releases for hasMediumOrHighAttention
const mediumRelease = {
@@ -595,17 +589,16 @@ describe('useReleaseStore', () => {
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot when new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should show red dot when new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
vi.mocked(semverCompare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(true)
})
@@ -617,9 +610,8 @@ describe('useReleaseStore', () => {
vi.mocked(isElectron).mockReturnValue(false)
})
it('should NOT show toast even when all other conditions are met', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should NOT show toast even when all other conditions are met', () => {
vi.mocked(semverCompare).mockReturnValue(1)
// Set up all conditions that would normally show toast
const mediumRelease = {
@@ -632,16 +624,14 @@ describe('useReleaseStore', () => {
expect(store.shouldShowToast).toBe(false)
})
it('should NOT show red dot even when new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should NOT show red dot even when new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show toast regardless of attention level', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should NOT show toast regardless of attention level', () => {
vi.mocked(semverCompare).mockReturnValue(1)
// Test with high attention releases
const highRelease = {
@@ -659,19 +649,18 @@ describe('useReleaseStore', () => {
expect(store.shouldShowToast).toBe(false)
})
it('should NOT show red dot even with high attention release', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should NOT show red dot even with high attention release', () => {
vi.mocked(semverCompare).mockReturnValue(1)
store.releases = [{ ...mockRelease, attention: 'high' as const }]
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show popup even for latest version', async () => {
it('should NOT show popup even for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
vi.mocked(semverCompare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(false)
})

View File

@@ -29,14 +29,15 @@
"rootDir": "./"
},
"include": [
"src/**/*",
".storybook/**/*",
"eslint.config.ts",
"global.d.ts",
"knip.config.ts",
"src/**/*.vue",
"src/**/*",
"src/types/**/*.d.ts",
"tests-ui/**/*",
"global.d.ts",
"eslint.config.ts",
"vite.config.mts",
"knip.config.ts",
".storybook/**/*"
"vitest.config.ts",
]
}

View File

@@ -31,7 +31,8 @@ export default defineConfig({
ignored: [
'**/coverage/**',
'**/playwright-report/**',
'**/*.{test,spec}.ts'
'**/*.{test,spec}.ts',
'*.config.{ts,mts}'
]
},
proxy: {

View File

@@ -32,7 +32,8 @@ export default defineConfig({
'**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
'src/lib/litegraph/test/**'
]
],
silent: 'passed-only'
},
resolve: {
alias: {