mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
8 Commits
drjkl/roun
...
drjkl/seco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f0afff729 | ||
|
|
3a2dbe68a3 | ||
|
|
bbec515a7b | ||
|
|
14f11cd51e | ||
|
|
14369c08a3 | ||
|
|
788f50834c | ||
|
|
75fd4f0e67 | ||
|
|
3946d7b5ff |
@@ -300,6 +300,12 @@ When referencing Comfy-Org repos:
|
||||
|
||||
Rules for agent-based coding tasks.
|
||||
|
||||
### Chrome DevTools MCP
|
||||
|
||||
When using `take_snapshot` to inspect dropdowns, listboxes, or other components with dynamic options:
|
||||
- Use `verbose: true` to see the full accessibility tree including list items
|
||||
- Non-verbose snapshots often omit nested options in comboboxes/listboxes
|
||||
|
||||
### Temporary Files
|
||||
|
||||
- Put planning documents under `/temp/plans/`
|
||||
|
||||
@@ -38,6 +38,10 @@ test.describe('Version Mismatch Warnings', () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.VersionCompatibility.DisableWarnings',
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('should show version mismatch warnings when installed version lower than required', async ({
|
||||
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -30,6 +30,7 @@ interface Window {
|
||||
badge?: string
|
||||
}
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
- Clear public interfaces
|
||||
- Restrict extension access
|
||||
- Clean up subscriptions
|
||||
- Only expose state/actions that are used externally; keep internal state private
|
||||
|
||||
## General Guidelines
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onMounted } from 'vue'
|
||||
@@ -21,15 +20,13 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { electronAPI, isElectron } from './utils/envUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
workspaceStore.shiftDown = e.shiftKey
|
||||
}
|
||||
useEventListener(window, 'keydown', handleKey)
|
||||
useEventListener(window, 'keyup', handleKey)
|
||||
|
||||
const showContextMenu = (event: MouseEvent) => {
|
||||
const { target } = event
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
synced with the stateStorage (localStorage). -->
|
||||
<LiteGraphCanvasSplitterOverlay v-if="comfyAppReady">
|
||||
<template v-if="showUI" #workflow-tabs>
|
||||
<!-- Native drag area for Electron (when tabs are NOT in topbar) -->
|
||||
<div
|
||||
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
|
||||
class="app-drag fixed top-0 left-0 z-10 h-[var(--comfy-topbar-height)] w-full"
|
||||
/>
|
||||
<div
|
||||
v-if="workflowTabsPosition === 'Topbar'"
|
||||
class="workflow-tabs-container pointer-events-auto relative h-9.5 w-full"
|
||||
>
|
||||
<!-- Native drag area for Electron -->
|
||||
<div
|
||||
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
|
||||
class="app-drag fixed top-0 left-0 z-10 h-[var(--comfy-topbar-height)] w-full"
|
||||
/>
|
||||
<div
|
||||
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
|
||||
>
|
||||
@@ -93,7 +93,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { until, useEventListener } from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -129,7 +129,7 @@ import { useCopy } from '@/composables/useCopy'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { mergeCustomNodesI18n, t } from '@/i18n'
|
||||
import { t } from '@/i18n'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
@@ -144,12 +144,15 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { UnauthorizedError } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { newUserService } from '@/services/newUserService'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -175,11 +178,16 @@ const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const toastStore = useToastStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const bootstrapStore = useBootstrapStore()
|
||||
const { isI18nReady, i18nError } = storeToRefs(bootstrapStore)
|
||||
const { isReady: isSettingsReady, error: settingsError } =
|
||||
storeToRefs(settingStore)
|
||||
|
||||
const betaMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
@@ -217,13 +225,11 @@ const handleVueNodeLifecycleReset = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => canvasStore.currentGraph, handleVueNodeLifecycleReset)
|
||||
|
||||
watch(
|
||||
() => canvasStore.isInSubgraph,
|
||||
async (newValue, oldValue) => {
|
||||
if (oldValue && !newValue) {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
() => [canvasStore.currentGraph, canvasStore.isInSubgraph] as const,
|
||||
async ([_graph, isInSubgraph], [_prevGraph, wasInSubgraph]) => {
|
||||
if (wasInSubgraph && !isInSubgraph) {
|
||||
workflowStore.updateActiveGraph()
|
||||
}
|
||||
await handleVueNodeLifecycleReset()
|
||||
}
|
||||
@@ -286,15 +292,18 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
let paletteWatcherRunId = 0
|
||||
watch(
|
||||
[() => canvasStore.canvas, () => settingStore.get('Comfy.ColorPalette')],
|
||||
async ([canvas, currentPaletteId]) => {
|
||||
if (!canvas) return
|
||||
|
||||
const runId = ++paletteWatcherRunId
|
||||
await colorPaletteService.loadColorPalette(currentPaletteId)
|
||||
if (runId !== paletteWatcherRunId) return
|
||||
}
|
||||
)
|
||||
|
||||
let backgroundWatcherRunId = 0
|
||||
watch(
|
||||
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
|
||||
async () => {
|
||||
@@ -302,8 +311,10 @@ watch(
|
||||
const currentPaletteId = colorPaletteStore.activePaletteId
|
||||
if (!currentPaletteId) return
|
||||
|
||||
const runId = ++backgroundWatcherRunId
|
||||
// Reload color palette to apply background image
|
||||
await colorPaletteService.loadColorPalette(currentPaletteId)
|
||||
if (runId !== backgroundWatcherRunId) return
|
||||
// Mark background canvas as dirty
|
||||
canvasStore.canvas.setDirty(false, true)
|
||||
}
|
||||
@@ -311,7 +322,10 @@ watch(
|
||||
watch(
|
||||
() => colorPaletteStore.activePaletteId,
|
||||
async (newValue) => {
|
||||
await settingStore.set('Comfy.ColorPalette', newValue)
|
||||
// Guard against ping-pong: only set if value actually differs
|
||||
if (newValue && settingStore.get('Comfy.ColorPalette') !== newValue) {
|
||||
await settingStore.set('Comfy.ColorPalette', newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -322,7 +336,7 @@ watch(
|
||||
([nodeLocationProgressStates, canvas]) => {
|
||||
if (!canvas?.graph) return
|
||||
for (const node of canvas.graph.nodes) {
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(node.id)
|
||||
const nodeLocatorId = workflowStore.nodeIdToNodeLocatorId(node.id)
|
||||
const progressState = nodeLocationProgressStates[nodeLocatorId]
|
||||
if (progressState && progressState.state === 'running') {
|
||||
node.progress = progressState.value / progressState.max
|
||||
@@ -333,8 +347,7 @@ watch(
|
||||
|
||||
// Force canvas redraw to ensure progress updates are visible
|
||||
canvas.setDirty(true, false)
|
||||
},
|
||||
{ deep: true }
|
||||
}
|
||||
)
|
||||
|
||||
// Update node slot errors for LiteGraph nodes
|
||||
@@ -386,17 +399,9 @@ useEventListener(
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
const loadCustomNodesI18n = async () => {
|
||||
try {
|
||||
const i18nData = await api.getCustomNodesI18n()
|
||||
mergeCustomNodesI18n(i18nData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom nodes i18n', error)
|
||||
}
|
||||
}
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const commandStore = useCommandStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
// Set up invite loader during setup phase so useRoute/useRouter work correctly
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
@@ -404,38 +409,90 @@ useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
|
||||
useGlobalLitegraph()
|
||||
useContextMenuTranslation()
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
|
||||
// Start watching for locale change after the initial value is loaded.
|
||||
let localeWatcherRunId = 0
|
||||
watch(
|
||||
() => settingStore.get('Comfy.Locale'),
|
||||
async (_newLocale, oldLocale) => {
|
||||
if (!oldLocale) return
|
||||
const runId = ++localeWatcherRunId
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
if (runId !== localeWatcherRunId) return
|
||||
await Promise.all([
|
||||
until(() => isSettingsReady.value || !!settingsError.value).toBe(true),
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true)
|
||||
])
|
||||
if (runId !== localeWatcherRunId) return
|
||||
if (settingsError.value || i18nError.value) {
|
||||
console.warn(
|
||||
'Somehow the Locale setting was changed while the settings or i18n had a setup error'
|
||||
)
|
||||
}
|
||||
await commandStore.execute('Comfy.RefreshNodeDefinitions')
|
||||
if (runId !== localeWatcherRunId) return
|
||||
await useWorkflowService().reloadCurrentWorkflow()
|
||||
}
|
||||
)
|
||||
useEventListener(
|
||||
() => canvasStore.canvas?.canvas,
|
||||
'litegraph:set-graph',
|
||||
() => {
|
||||
workflowStore.updateActiveGraph()
|
||||
}
|
||||
)
|
||||
|
||||
let disposed = false
|
||||
let prevOnSelectionChange: typeof comfyApp.canvas.onSelectionChange | null =
|
||||
null
|
||||
|
||||
onMounted(async () => {
|
||||
useGlobalLitegraph()
|
||||
useContextMenuTranslation()
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
useVueFeatureFlags()
|
||||
|
||||
comfyApp.vueAppReady = true
|
||||
|
||||
workspaceStore.spinner = true
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init()
|
||||
await loadCustomNodesI18n()
|
||||
try {
|
||||
await settingStore.loadSettingValues()
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
if (disposed) return
|
||||
|
||||
if (settingsError.value) {
|
||||
if (settingsError.value instanceof UnauthorizedError) {
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
} else {
|
||||
throw error
|
||||
return
|
||||
}
|
||||
throw settingsError.value
|
||||
}
|
||||
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
await newUserService().initializeIfNewUser(settingStore)
|
||||
// Wait for both i18n and newUserService in parallel
|
||||
// (newUserService only needs settings, not i18n)
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
newUserService().initializeIfNewUser(settingStore)
|
||||
])
|
||||
if (disposed) return
|
||||
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
'[GraphCanvas] Failed to load custom nodes i18n:',
|
||||
i18nError.value
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
if (disposed) return
|
||||
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
@@ -448,6 +505,7 @@ onMounted(async () => {
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
|
||||
prevOnSelectionChange = comfyApp.canvas.onSelectionChange
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
() => canvasStore.updateSelectedItems()
|
||||
@@ -460,10 +518,13 @@ onMounted(async () => {
|
||||
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
if (disposed) return
|
||||
|
||||
workflowPersistence.restoreWorkflowTabsState()
|
||||
|
||||
// Load template from URL if present
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
if (disposed) return
|
||||
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||
// Uses watch because feature flags load asynchronously - flag may be false initially
|
||||
@@ -487,29 +548,14 @@ onMounted(async () => {
|
||||
const releaseStore = useReleaseStore()
|
||||
void releaseStore.initialize()
|
||||
|
||||
// Start watching for locale change after the initial value is loaded.
|
||||
watch(
|
||||
() => settingStore.get('Comfy.Locale'),
|
||||
async () => {
|
||||
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
|
||||
await useWorkflowService().reloadCurrentWorkflow()
|
||||
}
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
vueNodeLifecycle.cleanup()
|
||||
if (prevOnSelectionChange && comfyApp.canvas) {
|
||||
comfyApp.canvas.onSelectionChange = prevOnSelectionChange
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -71,6 +71,9 @@ export const useNodeBadge = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (extensionStore.isExtensionInstalled('Comfy.NodeBadge')) return
|
||||
|
||||
// TODO: Fix the composables and watchers being setup in onMounted
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
watch(
|
||||
|
||||
@@ -167,25 +167,30 @@ describe('useCoreCommands', () => {
|
||||
return {
|
||||
get: vi.fn().mockReturnValue(getReturnValue),
|
||||
addSetting: vi.fn(),
|
||||
loadSettingValues: vi.fn(),
|
||||
load: vi.fn(),
|
||||
set: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
getDefaultValue: vi.fn(),
|
||||
isReady: true,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
settingValues: {},
|
||||
settingsById: {},
|
||||
$id: 'setting',
|
||||
$state: {
|
||||
settingValues: {},
|
||||
settingsById: {}
|
||||
settingsById: {},
|
||||
isReady: true,
|
||||
isLoading: false,
|
||||
error: undefined
|
||||
},
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {}
|
||||
} as ReturnType<typeof useSettingStore>
|
||||
_customProperties: new Set()
|
||||
} satisfies ReturnType<typeof useSettingStore>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -67,10 +67,9 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
@@ -14,6 +14,7 @@ import { VueFire, VueFireAuth } from 'vuefire'
|
||||
import { getFirebaseConfig } from '@/config/firebase'
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import router from '@/router'
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
|
||||
import App from './App.vue'
|
||||
// Intentionally relative import to ensure the CSS is loaded in the right order (after litegraph.css)
|
||||
@@ -30,6 +31,9 @@ if (isCloud) {
|
||||
const { refreshRemoteConfig } =
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
const { initGtm } = await import('@/platform/telemetry/gtm')
|
||||
initGtm()
|
||||
}
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
@@ -43,6 +47,7 @@ const firebaseApp = initializeApp(getFirebaseConfig())
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
Sentry.init({
|
||||
app,
|
||||
dsn: __SENTRY_DSN__,
|
||||
@@ -88,4 +93,7 @@ app
|
||||
modules: [VueFireAuth()]
|
||||
})
|
||||
|
||||
const bootstrapStore = useBootstrapStore(pinia)
|
||||
void bootstrapStore.startStoreBootstrap()
|
||||
|
||||
app.mount('#vue-app')
|
||||
|
||||
@@ -206,6 +206,45 @@ describe('useSubscription', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('pushes purchase event after a pending subscription completes', async () => {
|
||||
window.dataLayer = []
|
||||
localStorage.setItem(
|
||||
'pending_subscription_purchase',
|
||||
JSON.stringify({
|
||||
tierKey: 'creator',
|
||||
billingCycle: 'monthly',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
subscription_tier: 'CREATOR',
|
||||
subscription_duration: 'MONTHLY'
|
||||
})
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { fetchStatus } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
|
||||
expect(window.dataLayer).toHaveLength(1)
|
||||
expect(window.dataLayer?.[0]).toMatchObject({
|
||||
event: 'purchase',
|
||||
transaction_id: 'sub_123',
|
||||
currency: 'USD',
|
||||
item_id: 'monthly_creator',
|
||||
item_variant: 'monthly',
|
||||
item_category: 'subscription',
|
||||
quantity: 1
|
||||
})
|
||||
expect(localStorage.getItem('pending_subscription_purchase')).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle fetch errors gracefully', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
|
||||
@@ -8,12 +8,20 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { pushDataLayerEvent } from '@/platform/telemetry/gtm'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
getTierPrice,
|
||||
TIER_TO_KEY
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
clearPendingSubscriptionPurchase,
|
||||
getPendingSubscriptionPurchase
|
||||
} from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
|
||||
|
||||
@@ -93,7 +101,42 @@ function useSubscriptionInternal() {
|
||||
: baseName
|
||||
})
|
||||
|
||||
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
|
||||
function buildApiUrl(path: string): string {
|
||||
return `${getComfyApiBaseUrl()}${path}`
|
||||
}
|
||||
|
||||
function trackSubscriptionPurchase(
|
||||
status: CloudSubscriptionStatusResponse | null
|
||||
): void {
|
||||
if (!status?.is_active || !status.subscription_id) return
|
||||
|
||||
const pendingPurchase = getPendingSubscriptionPurchase()
|
||||
if (!pendingPurchase) return
|
||||
|
||||
const { tierKey, billingCycle } = pendingPurchase
|
||||
const isYearly = billingCycle === 'yearly'
|
||||
const baseName = t(`subscription.tiers.${tierKey}.name`)
|
||||
const planName = isYearly
|
||||
? t('subscription.tierNameYearly', { name: baseName })
|
||||
: baseName
|
||||
const unitPrice = getTierPrice(tierKey, isYearly)
|
||||
const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice
|
||||
|
||||
pushDataLayerEvent({
|
||||
event: 'purchase',
|
||||
transaction_id: status.subscription_id,
|
||||
value,
|
||||
currency: 'USD',
|
||||
item_id: `${billingCycle}_${tierKey}`,
|
||||
item_name: planName,
|
||||
item_category: 'subscription',
|
||||
item_variant: billingCycle,
|
||||
price: value,
|
||||
quantity: 1
|
||||
})
|
||||
|
||||
clearPendingSubscriptionPurchase()
|
||||
}
|
||||
|
||||
const fetchStatus = wrapWithErrorHandlingAsync(
|
||||
fetchSubscriptionStatus,
|
||||
@@ -194,6 +237,12 @@ function useSubscriptionInternal() {
|
||||
|
||||
const statusData = await response.json()
|
||||
subscriptionStatus.value = statusData
|
||||
|
||||
try {
|
||||
await trackSubscriptionPurchase(statusData)
|
||||
} catch (error) {
|
||||
console.error('Failed to track subscription purchase', error)
|
||||
}
|
||||
return statusData
|
||||
}
|
||||
|
||||
@@ -203,6 +252,10 @@ function useSubscriptionInternal() {
|
||||
if (loggedIn) {
|
||||
try {
|
||||
await fetchSubscriptionStatus()
|
||||
} catch (error) {
|
||||
// Network errors are expected during navigation/component unmount
|
||||
// and when offline - log for debugging but don't surface to user
|
||||
console.error('Failed to fetch subscription status:', error)
|
||||
} finally {
|
||||
isInitialized.value = true
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
type CheckoutTier = TierKey | `${TierKey}-yearly`
|
||||
@@ -78,6 +79,7 @@ export async function performSubscriptionCheckout(
|
||||
const data = await response.json()
|
||||
|
||||
if (data.checkout_url) {
|
||||
startSubscriptionPurchaseTracking(tierKey, currentBillingCycle)
|
||||
if (openInNewTab) {
|
||||
window.open(data.checkout_url, '_blank')
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
type PendingSubscriptionPurchase = {
|
||||
tierKey: TierKey
|
||||
billingCycle: BillingCycle
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'pending_subscription_purchase'
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours
|
||||
const VALID_TIERS: TierKey[] = ['standard', 'creator', 'pro', 'founder']
|
||||
const VALID_CYCLES: BillingCycle[] = ['monthly', 'yearly']
|
||||
|
||||
const safeRemove = (): void => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// Ignore storage errors (e.g. private browsing mode)
|
||||
}
|
||||
}
|
||||
|
||||
export function startSubscriptionPurchaseTracking(
|
||||
tierKey: TierKey,
|
||||
billingCycle: BillingCycle
|
||||
): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const payload: PendingSubscriptionPurchase = {
|
||||
tierKey,
|
||||
billingCycle,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
||||
} catch {
|
||||
// Ignore storage errors (e.g. private browsing mode)
|
||||
}
|
||||
}
|
||||
|
||||
export function getPendingSubscriptionPurchase(): PendingSubscriptionPurchase | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
|
||||
const parsed = JSON.parse(raw) as PendingSubscriptionPurchase
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
safeRemove()
|
||||
return null
|
||||
}
|
||||
|
||||
const { tierKey, billingCycle, timestamp } = parsed
|
||||
if (
|
||||
!VALID_TIERS.includes(tierKey) ||
|
||||
!VALID_CYCLES.includes(billingCycle) ||
|
||||
typeof timestamp !== 'number'
|
||||
) {
|
||||
safeRemove()
|
||||
return null
|
||||
}
|
||||
|
||||
if (Date.now() - timestamp > MAX_AGE_MS) {
|
||||
safeRemove()
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
} catch {
|
||||
safeRemove()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingSubscriptionPurchase(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
safeRemove()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -33,7 +34,7 @@ describe('useSettingStore', () => {
|
||||
let store: ReturnType<typeof useSettingStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useSettingStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -43,20 +44,20 @@ describe('useSettingStore', () => {
|
||||
expect(store.settingsById).toEqual({})
|
||||
})
|
||||
|
||||
describe('loadSettingValues', () => {
|
||||
describe('load', () => {
|
||||
it('should load settings from API', async () => {
|
||||
const mockSettings = { 'test.setting': 'value' }
|
||||
vi.mocked(api.getSettings).mockResolvedValue(
|
||||
mockSettings as Partial<Settings> as Settings
|
||||
)
|
||||
|
||||
await store.loadSettingValues()
|
||||
await store.load()
|
||||
|
||||
expect(store.settingValues).toEqual(mockSettings)
|
||||
expect(api.getSettings).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw error if settings are loaded after registration', async () => {
|
||||
it('should set error if settings are loaded after registration', async () => {
|
||||
const setting: SettingParams = {
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
@@ -65,9 +66,14 @@ describe('useSettingStore', () => {
|
||||
}
|
||||
store.addSetting(setting)
|
||||
|
||||
await expect(store.loadSettingValues()).rejects.toThrow(
|
||||
'Setting values must be loaded before any setting is registered.'
|
||||
)
|
||||
await store.load()
|
||||
|
||||
expect(store.error).toBeInstanceOf(Error)
|
||||
if (store.error instanceof Error) {
|
||||
expect(store.error.message).toBe(
|
||||
'Setting values must be loaded before any setting is registered.'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,18 +91,24 @@ describe('useSettingStore', () => {
|
||||
expect(store.settingsById['test.setting']).toEqual(setting)
|
||||
})
|
||||
|
||||
it('should throw error for duplicate setting ID', () => {
|
||||
it('should warn and skip for duplicate setting ID', () => {
|
||||
const setting: SettingParams = {
|
||||
id: 'test.setting',
|
||||
name: 'test.setting',
|
||||
type: 'text',
|
||||
defaultValue: 'default'
|
||||
}
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
store.addSetting(setting)
|
||||
expect(() => store.addSetting(setting)).toThrow(
|
||||
'Setting test.setting must have a unique ID.'
|
||||
store.addSetting(setting)
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Setting already registered: test.setting'
|
||||
)
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should migrate deprecated values', () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { retry } from 'es-toolkit'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { until, useAsyncState } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { compare, valid } from 'semver'
|
||||
import { ref } from 'vue'
|
||||
@@ -47,6 +49,39 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
const settingValues = ref<Record<string, any>>({})
|
||||
const settingsById = ref<Record<string, SettingParams>>({})
|
||||
|
||||
const {
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
execute: loadSettingValues
|
||||
} = useAsyncState(
|
||||
async () => {
|
||||
if (Object.keys(settingsById.value).length) {
|
||||
throw new Error(
|
||||
'Setting values must be loaded before any setting is registered.'
|
||||
)
|
||||
}
|
||||
settingValues.value = await retry(() => api.getSettings(), {
|
||||
retries: 3,
|
||||
delay: (attempt) => Math.min(1000 * Math.pow(2, attempt), 8000)
|
||||
})
|
||||
await migrateZoomThresholdToFontSize()
|
||||
},
|
||||
undefined,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (isReady.value) return
|
||||
|
||||
if (isLoading.value) {
|
||||
await until(isLoading).toBe(false)
|
||||
return
|
||||
}
|
||||
|
||||
await loadSettingValues()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a setting's value exists, i.e. if the user has set it manually.
|
||||
* @param key - The key of the setting to check.
|
||||
@@ -170,7 +205,11 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
throw new Error('Settings must have an ID')
|
||||
}
|
||||
if (setting.id in settingsById.value) {
|
||||
throw new Error(`Setting ${setting.id} must have a unique ID.`)
|
||||
// Setting already registered - skip to allow component remounting
|
||||
// TODO: Add store reset methods to bootstrapStore and settingStore, then
|
||||
// replace window.location.reload() with router.push() in SidebarLogoutIcon.vue
|
||||
console.warn(`Setting already registered: ${setting.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
settingsById.value[setting.id] = setting
|
||||
@@ -184,22 +223,6 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
onChange(setting, get(setting.id), undefined)
|
||||
}
|
||||
|
||||
/*
|
||||
* Load setting values from server.
|
||||
* This needs to be called before any setting is registered.
|
||||
*/
|
||||
async function loadSettingValues() {
|
||||
if (Object.keys(settingsById.value).length) {
|
||||
throw new Error(
|
||||
'Setting values must be loaded before any setting is registered.'
|
||||
)
|
||||
}
|
||||
settingValues.value = await api.getSettings()
|
||||
|
||||
// Migrate old zoom threshold setting to new font size setting
|
||||
await migrateZoomThresholdToFontSize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the old zoom threshold setting to the new font size setting.
|
||||
* Preserves the exact zoom threshold behavior by converting it to equivalent font size.
|
||||
@@ -242,8 +265,11 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
return {
|
||||
settingValues,
|
||||
settingsById,
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
load,
|
||||
addSetting,
|
||||
loadSettingValues,
|
||||
set,
|
||||
get,
|
||||
exists,
|
||||
|
||||
43
src/platform/telemetry/gtm.ts
Normal file
43
src/platform/telemetry/gtm.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const GTM_CONTAINER_ID = 'GTM-NP9JM6K7'
|
||||
|
||||
let isInitialized = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
export function initGtm(): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
if (typeof document === 'undefined') return
|
||||
if (isInitialized) return
|
||||
|
||||
if (!initPromise) {
|
||||
initPromise = new Promise((resolve) => {
|
||||
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
|
||||
dataLayer.push({
|
||||
'gtm.start': Date.now(),
|
||||
event: 'gtm.js'
|
||||
})
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_CONTAINER_ID}`
|
||||
|
||||
const finalize = () => {
|
||||
isInitialized = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
script.addEventListener('load', finalize, { once: true })
|
||||
script.addEventListener('error', finalize, { once: true })
|
||||
document.head?.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
void initPromise
|
||||
}
|
||||
|
||||
export function pushDataLayerEvent(event: Record<string, unknown>): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
|
||||
dataLayer.push(event)
|
||||
}
|
||||
@@ -190,7 +190,7 @@ describe('useReleaseService', () => {
|
||||
})
|
||||
|
||||
it('should set loading state correctly', async () => {
|
||||
let resolvePromise: (value: any) => void
|
||||
let resolvePromise: (value: unknown) => void
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { compare, valid } from 'semver'
|
||||
import type { Mock } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
|
||||
// Mock the dependencies
|
||||
@@ -19,9 +22,25 @@ vi.mock('@vueuse/core', () => ({
|
||||
|
||||
describe('useReleaseStore', () => {
|
||||
let store: ReturnType<typeof useReleaseStore>
|
||||
let mockReleaseService: any
|
||||
let mockSettingStore: any
|
||||
let mockSystemStatsStore: any
|
||||
let mockReleaseService: {
|
||||
getReleases: Mock
|
||||
isLoading: ReturnType<typeof ref<boolean>>
|
||||
error: ReturnType<typeof ref<string | null>>
|
||||
}
|
||||
let mockSettingStore: { get: Mock; set: Mock }
|
||||
let mockSystemStatsStore: {
|
||||
systemStats: {
|
||||
system: {
|
||||
comfyui_version: string
|
||||
argv?: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
devices?: unknown[]
|
||||
} | null
|
||||
isInitialized: boolean
|
||||
refetchSystemStats: Mock
|
||||
getFormFactor: Mock
|
||||
}
|
||||
|
||||
const mockRelease = {
|
||||
id: 1,
|
||||
@@ -38,11 +57,11 @@ describe('useReleaseStore', () => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup mock services
|
||||
// Setup mock services with proper refs
|
||||
mockReleaseService = {
|
||||
getReleases: vi.fn(),
|
||||
isLoading: { value: false },
|
||||
error: { value: null }
|
||||
isLoading: ref(false),
|
||||
error: ref(null)
|
||||
}
|
||||
|
||||
mockSettingStore = {
|
||||
@@ -68,9 +87,21 @@ describe('useReleaseStore', () => {
|
||||
const { useSystemStatsStore } = await import('@/stores/systemStatsStore')
|
||||
const { isElectron } = await import('@/utils/envUtil')
|
||||
|
||||
vi.mocked(useReleaseService).mockReturnValue(mockReleaseService)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore)
|
||||
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
|
||||
vi.mocked(useReleaseService).mockReturnValue(
|
||||
mockReleaseService as Partial<
|
||||
ReturnType<typeof useReleaseService>
|
||||
> as ReturnType<typeof useReleaseService>
|
||||
)
|
||||
vi.mocked(useSettingStore).mockReturnValue(
|
||||
mockSettingStore as Partial<
|
||||
ReturnType<typeof useSettingStore>
|
||||
> as ReturnType<typeof useSettingStore>
|
||||
)
|
||||
vi.mocked(useSystemStatsStore).mockReturnValue(
|
||||
mockSystemStatsStore as Partial<
|
||||
ReturnType<typeof useSystemStatsStore>
|
||||
> as ReturnType<typeof useSystemStatsStore>
|
||||
)
|
||||
vi.mocked(isElectron).mockReturnValue(true)
|
||||
vi.mocked(valid).mockReturnValue('1.0.0')
|
||||
|
||||
@@ -171,7 +202,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should show popup for latest version', () => {
|
||||
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
|
||||
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
|
||||
vi.mocked(compare).mockReturnValue(0)
|
||||
|
||||
@@ -213,7 +244,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should not show popup even for latest version', () => {
|
||||
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
|
||||
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
|
||||
vi.mocked(compare).mockReturnValue(0)
|
||||
|
||||
@@ -265,7 +296,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should skip fetching when --disable-api-nodes is present', async () => {
|
||||
mockSystemStatsStore.systemStats.system.argv = ['--disable-api-nodes']
|
||||
mockSystemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
|
||||
|
||||
await store.initialize()
|
||||
|
||||
@@ -274,7 +305,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should skip fetching when --disable-api-nodes is one of multiple args', async () => {
|
||||
mockSystemStatsStore.systemStats.system.argv = [
|
||||
mockSystemStatsStore.systemStats!.system.argv = [
|
||||
'--port',
|
||||
'8080',
|
||||
'--disable-api-nodes',
|
||||
@@ -288,7 +319,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should fetch normally when --disable-api-nodes is not present', async () => {
|
||||
mockSystemStatsStore.systemStats.system.argv = [
|
||||
mockSystemStatsStore.systemStats!.system.argv = [
|
||||
'--port',
|
||||
'8080',
|
||||
'--verbose'
|
||||
@@ -302,7 +333,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should fetch normally when argv is undefined', async () => {
|
||||
mockSystemStatsStore.systemStats.system.argv = undefined
|
||||
mockSystemStatsStore.systemStats!.system.argv = undefined
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.initialize()
|
||||
@@ -330,8 +361,8 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should set loading state correctly', async () => {
|
||||
let resolvePromise: (value: any) => void
|
||||
const promise = new Promise((resolve) => {
|
||||
let resolvePromise: (value: ReleaseNote[] | null) => void
|
||||
const promise = new Promise<ReleaseNote[] | null>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
@@ -372,7 +403,7 @@ describe('useReleaseStore', () => {
|
||||
|
||||
describe('--disable-api-nodes argument handling', () => {
|
||||
it('should skip fetchReleases when --disable-api-nodes is present', async () => {
|
||||
mockSystemStatsStore.systemStats.system.argv = ['--disable-api-nodes']
|
||||
mockSystemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
|
||||
|
||||
await store.fetchReleases()
|
||||
|
||||
@@ -381,7 +412,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should skip fetchReleases when --disable-api-nodes is among other args', async () => {
|
||||
mockSystemStatsStore.systemStats.system.argv = [
|
||||
mockSystemStatsStore.systemStats!.system.argv = [
|
||||
'--port',
|
||||
'8080',
|
||||
'--disable-api-nodes',
|
||||
@@ -395,7 +426,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should proceed with fetchReleases when --disable-api-nodes is not present', async () => {
|
||||
mockSystemStatsStore.systemStats.system.argv = [
|
||||
mockSystemStatsStore.systemStats!.system.argv = [
|
||||
'--port',
|
||||
'8080',
|
||||
'--verbose'
|
||||
@@ -407,8 +438,8 @@ describe('useReleaseStore', () => {
|
||||
expect(mockReleaseService.getReleases).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should proceed with fetchReleases when argv is null', async () => {
|
||||
mockSystemStatsStore.systemStats.system.argv = null
|
||||
it('should proceed with fetchReleases when argv is undefined', async () => {
|
||||
mockSystemStatsStore.systemStats!.system.argv = undefined
|
||||
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
|
||||
|
||||
await store.fetchReleases()
|
||||
@@ -515,7 +546,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should show popup for latest version', () => {
|
||||
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0' // Same as release
|
||||
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
|
||||
@@ -592,7 +623,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should show popup for latest version', () => {
|
||||
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
|
||||
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
|
||||
vi.mocked(compare).mockReturnValue(0)
|
||||
|
||||
@@ -649,7 +680,7 @@ describe('useReleaseStore', () => {
|
||||
})
|
||||
|
||||
it('should NOT show popup even for latest version', () => {
|
||||
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
|
||||
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
|
||||
|
||||
vi.mocked(compare).mockReturnValue(0)
|
||||
|
||||
|
||||
@@ -40,17 +40,20 @@ vi.mock('@/scripts/api', () => ({
|
||||
// Mock vue-i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: any) => {
|
||||
t: (key: string, params?: Record<string, string | number> | unknown) => {
|
||||
if (key === 'g.versionMismatchWarning')
|
||||
return 'Version Compatibility Warning'
|
||||
if (key === 'g.versionMismatchWarningMessage' && params) {
|
||||
return `${params.warning}: ${params.detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.`
|
||||
const p = params as Record<string, string>
|
||||
return `${p.warning}: ${p.detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.`
|
||||
}
|
||||
if (key === 'g.frontendOutdated' && params) {
|
||||
return `Frontend version ${params.frontendVersion} is outdated. Backend requires ${params.requiredVersion} or higher.`
|
||||
const p = params as Record<string, string>
|
||||
return `Frontend version ${p.frontendVersion} is outdated. Backend requires ${p.requiredVersion} or higher.`
|
||||
}
|
||||
if (key === 'g.frontendNewer' && params) {
|
||||
return `Frontend version ${params.frontendVersion} may not be compatible with backend version ${params.backendVersion}.`
|
||||
const p = params as Record<string, string>
|
||||
return `Frontend version ${p.frontendVersion} may not be compatible with backend version ${p.backendVersion}.`
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
default: {
|
||||
@@ -12,13 +10,14 @@ vi.mock('@/config', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/systemStatsStore')
|
||||
const mockUseSystemStatsStore = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: mockUseSystemStatsStore
|
||||
}))
|
||||
|
||||
// Mock settingStore
|
||||
const mockUseSettingStore = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => false) // Default to warnings enabled (false = not disabled)
|
||||
}))
|
||||
useSettingStore: mockUseSettingStore
|
||||
}))
|
||||
|
||||
// Mock useStorage and until from VueUse
|
||||
@@ -28,10 +27,16 @@ vi.mock('@vueuse/core', () => ({
|
||||
until: vi.fn(() => Promise.resolve())
|
||||
}))
|
||||
|
||||
type MockSystemStatsStore = {
|
||||
systemStats: unknown
|
||||
isInitialized: boolean
|
||||
refetchSystemStats: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe('useVersionCompatibilityStore', () => {
|
||||
let store: ReturnType<typeof useVersionCompatibilityStore>
|
||||
let mockSystemStatsStore: any
|
||||
let mockSettingStore: any
|
||||
let mockSystemStatsStore: MockSystemStatsStore
|
||||
let mockSettingStore: { get: ReturnType<typeof vi.fn> }
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -49,8 +54,8 @@ describe('useVersionCompatibilityStore', () => {
|
||||
get: vi.fn(() => false) // Default to warnings enabled
|
||||
}
|
||||
|
||||
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore)
|
||||
mockUseSystemStatsStore.mockReturnValue(mockSystemStatsStore)
|
||||
mockUseSettingStore.mockReturnValue(mockSettingStore)
|
||||
|
||||
store = useVersionCompatibilityStore()
|
||||
})
|
||||
@@ -213,7 +218,9 @@ describe('useVersionCompatibilityStore', () => {
|
||||
|
||||
it('should not show warning when disabled via setting', async () => {
|
||||
// Enable the disable setting
|
||||
mockSettingStore.get.mockReturnValue(true)
|
||||
;(
|
||||
mockSettingStore as { get: ReturnType<typeof vi.fn> }
|
||||
).get.mockReturnValue(true)
|
||||
|
||||
// Set up version mismatch that would normally show warning
|
||||
mockSystemStatsStore.systemStats = {
|
||||
@@ -227,9 +234,9 @@ describe('useVersionCompatibilityStore', () => {
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.shouldShowWarning).toBe(false)
|
||||
expect(mockSettingStore.get).toHaveBeenCalledWith(
|
||||
'Comfy.VersionCompatibility.DisableWarnings'
|
||||
)
|
||||
expect(
|
||||
(mockSettingStore as { get: ReturnType<typeof vi.fn> }).get
|
||||
).toHaveBeenCalledWith('Comfy.VersionCompatibility.DisableWarnings')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import ReleaseNotificationToast from './ReleaseNotificationToast.vue'
|
||||
|
||||
interface TestWindow extends Window {
|
||||
electronAPI?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const { commandExecuteMock } = vi.hoisted(() => ({
|
||||
commandExecuteMock: vi.fn()
|
||||
}))
|
||||
@@ -192,7 +196,7 @@ describe('ReleaseNotificationToast', () => {
|
||||
value: mockWindowOpen,
|
||||
writable: true
|
||||
})
|
||||
;(window as any).electronAPI = {}
|
||||
;(window as TestWindow).electronAPI = {}
|
||||
|
||||
wrapper = mountComponent()
|
||||
await wrapper.vm.handleUpdate()
|
||||
@@ -203,7 +207,7 @@ describe('ReleaseNotificationToast', () => {
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(toastErrorHandlerMock).not.toHaveBeenCalled()
|
||||
|
||||
delete (window as any).electronAPI
|
||||
delete (window as TestWindow).electronAPI
|
||||
})
|
||||
|
||||
it('shows an error toast if the desktop updater flow fails in Electron', async () => {
|
||||
@@ -220,7 +224,7 @@ describe('ReleaseNotificationToast', () => {
|
||||
value: mockWindowOpen,
|
||||
writable: true
|
||||
})
|
||||
;(window as any).electronAPI = {}
|
||||
;(window as TestWindow).electronAPI = {}
|
||||
|
||||
wrapper = mountComponent()
|
||||
await wrapper.vm.handleUpdate()
|
||||
@@ -228,7 +232,7 @@ describe('ReleaseNotificationToast', () => {
|
||||
expect(toastErrorHandlerMock).toHaveBeenCalledWith(error)
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
|
||||
delete (window as any).electronAPI
|
||||
delete (window as TestWindow).electronAPI
|
||||
})
|
||||
|
||||
it('calls handleShowChangelog when learn more link is clicked', async () => {
|
||||
|
||||
@@ -165,7 +165,9 @@ describe('WhatsNewPopup', () => {
|
||||
wrapper = mountComponent()
|
||||
|
||||
// Call the close method directly instead of triggering DOM event
|
||||
await (wrapper.vm as any).closePopup()
|
||||
await (
|
||||
wrapper.vm as typeof wrapper.vm & { closePopup: () => Promise<void> }
|
||||
).closePopup()
|
||||
|
||||
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflow,
|
||||
LoadedComfyWorkflow
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
import {
|
||||
createMockCanvas,
|
||||
createMockChangeTracker
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// Add mock for api at the top of the file
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -67,9 +70,6 @@ describe('useWorkflowStore', () => {
|
||||
store = useWorkflowStore()
|
||||
bookmarkStore = useWorkflowBookmarkStore()
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
useWorkflowDraftStore().reset()
|
||||
|
||||
// Add default mock implementations
|
||||
vi.mocked(api.getUserData).mockResolvedValue({
|
||||
@@ -187,11 +187,12 @@ describe('useWorkflowStore', () => {
|
||||
it('should load and open a temporary workflow', async () => {
|
||||
// Create a test workflow
|
||||
const workflow = store.createTemporary('test.json')
|
||||
const mockWorkflowData = { nodes: [], links: [] }
|
||||
|
||||
// Mock the load response
|
||||
vi.spyOn(workflow, 'load').mockImplementation(async () => {
|
||||
workflow.changeTracker = { activeState: mockWorkflowData } as any
|
||||
workflow.changeTracker = createMockChangeTracker({
|
||||
workflow
|
||||
})
|
||||
return workflow as LoadedComfyWorkflow
|
||||
})
|
||||
|
||||
@@ -239,60 +240,6 @@ describe('useWorkflowStore', () => {
|
||||
expect(workflow.isModified).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers local draft snapshots when available', async () => {
|
||||
localStorage.clear()
|
||||
await syncRemoteWorkflows(['a.json'])
|
||||
const workflow = store.getWorkflowByPath('workflows/a.json')!
|
||||
|
||||
const draftGraph = {
|
||||
...defaultGraph,
|
||||
nodes: [...defaultGraph.nodes]
|
||||
}
|
||||
|
||||
useWorkflowDraftStore().saveDraft(workflow.path, {
|
||||
data: JSON.stringify(draftGraph),
|
||||
updatedAt: Date.now(),
|
||||
name: workflow.key,
|
||||
isTemporary: workflow.isTemporary
|
||||
})
|
||||
|
||||
vi.mocked(api.getUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
text: () => Promise.resolve(defaultGraphJSON)
|
||||
} as Response)
|
||||
|
||||
await workflow.load()
|
||||
|
||||
expect(workflow.isModified).toBe(true)
|
||||
expect(workflow.changeTracker?.activeState).toEqual(draftGraph)
|
||||
})
|
||||
|
||||
it('ignores stale drafts when server version is newer', async () => {
|
||||
await syncRemoteWorkflows(['a.json'])
|
||||
const workflow = store.getWorkflowByPath('workflows/a.json')!
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
|
||||
const draftSnapshot = {
|
||||
data: JSON.stringify(defaultGraph),
|
||||
updatedAt: Date.now(),
|
||||
name: workflow.key,
|
||||
isTemporary: workflow.isTemporary
|
||||
}
|
||||
|
||||
draftStore.saveDraft(workflow.path, draftSnapshot)
|
||||
workflow.lastModified = draftSnapshot.updatedAt + 1000
|
||||
|
||||
vi.mocked(api.getUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
text: () => Promise.resolve(defaultGraphJSON)
|
||||
} as Response)
|
||||
|
||||
await workflow.load()
|
||||
|
||||
expect(workflow.isModified).toBe(false)
|
||||
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should load and open a remote workflow', async () => {
|
||||
await syncRemoteWorkflows(['a.json', 'b.json'])
|
||||
|
||||
@@ -422,10 +369,11 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
// Mock super.rename
|
||||
vi.spyOn(Object.getPrototypeOf(workflow), 'rename').mockImplementation(
|
||||
async function (this: any, newPath: string) {
|
||||
this.path = newPath
|
||||
return this
|
||||
} as any
|
||||
async function (this: unknown, ...args: unknown[]) {
|
||||
const newPath = args[0] as string
|
||||
;(this as typeof workflow).path = newPath
|
||||
return this as typeof workflow
|
||||
}
|
||||
)
|
||||
|
||||
// Perform rename
|
||||
@@ -445,10 +393,11 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
// Mock super.rename
|
||||
vi.spyOn(Object.getPrototypeOf(workflow), 'rename').mockImplementation(
|
||||
async function (this: any, newPath: string) {
|
||||
this.path = newPath
|
||||
return this
|
||||
} as any
|
||||
async function (this: unknown, ...args: unknown[]) {
|
||||
const newPath = args[0] as string
|
||||
;(this as typeof workflow).path = newPath
|
||||
return this as typeof workflow
|
||||
}
|
||||
)
|
||||
|
||||
// Perform rename
|
||||
@@ -471,20 +420,6 @@ describe('useWorkflowStore', () => {
|
||||
expect(store.isOpen(workflow)).toBe(false)
|
||||
expect(store.getWorkflowByPath(workflow.path)).toBeNull()
|
||||
})
|
||||
|
||||
it('should remove draft when closing temporary workflow', async () => {
|
||||
const workflow = store.createTemporary('test.json')
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
draftStore.saveDraft(workflow.path, {
|
||||
data: defaultGraphJSON,
|
||||
updatedAt: Date.now(),
|
||||
name: workflow.key,
|
||||
isTemporary: true
|
||||
})
|
||||
expect(draftStore.getDraft(workflow.path)).toBeDefined()
|
||||
await store.closeWorkflow(workflow)
|
||||
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteWorkflow', () => {
|
||||
@@ -527,12 +462,9 @@ describe('useWorkflowStore', () => {
|
||||
await syncRemoteWorkflows(['test.json'])
|
||||
const workflow = store.getWorkflowByPath('workflows/test.json')!
|
||||
|
||||
// Mock the activeState
|
||||
const mockState = { nodes: [] }
|
||||
workflow.changeTracker = {
|
||||
activeState: mockState,
|
||||
reset: vi.fn()
|
||||
} as any
|
||||
workflow.changeTracker = createMockChangeTracker({
|
||||
workflow
|
||||
})
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () =>
|
||||
@@ -547,7 +479,9 @@ describe('useWorkflowStore', () => {
|
||||
await workflow.save()
|
||||
|
||||
// Verify the content was updated
|
||||
expect(workflow.content).toBe(JSON.stringify(mockState))
|
||||
expect(workflow.content).toBe(
|
||||
JSON.stringify(workflow.changeTracker!.activeState)
|
||||
)
|
||||
expect(workflow.changeTracker!.reset).toHaveBeenCalled()
|
||||
expect(workflow.isModified).toBe(false)
|
||||
})
|
||||
@@ -557,12 +491,9 @@ describe('useWorkflowStore', () => {
|
||||
const workflow = store.getWorkflowByPath('workflows/test.json')!
|
||||
workflow.isModified = false
|
||||
|
||||
// Mock the activeState
|
||||
const mockState = { nodes: [] }
|
||||
workflow.changeTracker = {
|
||||
activeState: mockState,
|
||||
reset: vi.fn()
|
||||
} as any
|
||||
workflow.changeTracker = createMockChangeTracker({
|
||||
workflow
|
||||
})
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () =>
|
||||
@@ -591,12 +522,9 @@ describe('useWorkflowStore', () => {
|
||||
const workflow = store.getWorkflowByPath('workflows/test.json')!
|
||||
workflow.isModified = true
|
||||
|
||||
// Mock the activeState
|
||||
const mockState = { nodes: [] }
|
||||
workflow.changeTracker = {
|
||||
activeState: mockState,
|
||||
reset: vi.fn()
|
||||
} as any
|
||||
workflow.changeTracker = createMockChangeTracker({
|
||||
workflow
|
||||
})
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () =>
|
||||
@@ -615,7 +543,9 @@ describe('useWorkflowStore', () => {
|
||||
expect(workflow.isModified).toBe(true)
|
||||
|
||||
expect(newWorkflow.path).toBe('workflows/new-test.json')
|
||||
expect(newWorkflow.content).toBe(JSON.stringify(mockState))
|
||||
expect(newWorkflow.content).toBe(
|
||||
JSON.stringify(workflow.changeTracker!.activeState)
|
||||
)
|
||||
expect(newWorkflow.isModified).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -623,13 +553,17 @@ describe('useWorkflowStore', () => {
|
||||
describe('Subgraphs', () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure canvas exists for these tests
|
||||
vi.mocked(comfyApp).canvas = { subgraph: null } as any
|
||||
vi.mocked(comfyApp).canvas = createMockCanvas({
|
||||
subgraph: undefined
|
||||
}) as typeof comfyApp.canvas
|
||||
|
||||
// Setup an active workflow as updateActiveGraph depends on it
|
||||
const workflow = store.createTemporary('test-subgraph-workflow.json')
|
||||
// Mock load to avoid actual file operations/parsing
|
||||
vi.spyOn(workflow, 'load').mockImplementation(async () => {
|
||||
workflow.changeTracker = { activeState: {} } as any // Minimal mock
|
||||
workflow.changeTracker = createMockChangeTracker({
|
||||
workflow
|
||||
})
|
||||
workflow.originalContent = '{}'
|
||||
workflow.content = '{}'
|
||||
return workflow as LoadedComfyWorkflow
|
||||
@@ -642,7 +576,7 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
it('should handle when comfyApp.canvas is not available', async () => {
|
||||
// Arrange
|
||||
vi.mocked(comfyApp).canvas = null as any // Simulate canvas not ready
|
||||
vi.mocked(comfyApp).canvas = null! as typeof comfyApp.canvas
|
||||
|
||||
// Act
|
||||
console.debug(store.isSubgraphActive)
|
||||
@@ -678,7 +612,7 @@ describe('useWorkflowStore', () => {
|
||||
{ name: 'Level 1 Subgraph' },
|
||||
{ name: 'Level 2 Subgraph' }
|
||||
]
|
||||
} as any
|
||||
} as Partial<Subgraph> as Subgraph
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph
|
||||
|
||||
// Mock isSubgraph to return true for our mockSubgraph
|
||||
@@ -701,7 +635,7 @@ describe('useWorkflowStore', () => {
|
||||
name: 'Initial Subgraph',
|
||||
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
|
||||
isRootGraph: false
|
||||
} as any
|
||||
} as Partial<Subgraph> as Subgraph
|
||||
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph
|
||||
|
||||
// Mock isSubgraph to return true for our initialSubgraph
|
||||
@@ -721,7 +655,9 @@ describe('useWorkflowStore', () => {
|
||||
const workflow2 = store.createTemporary('workflow2.json')
|
||||
// Mock load for the second workflow
|
||||
vi.spyOn(workflow2, 'load').mockImplementation(async () => {
|
||||
workflow2.changeTracker = { activeState: {} } as any
|
||||
workflow2.changeTracker = createMockChangeTracker({
|
||||
workflow: workflow2
|
||||
})
|
||||
workflow2.originalContent = '{}'
|
||||
workflow2.content = '{}'
|
||||
return workflow2 as LoadedComfyWorkflow
|
||||
@@ -748,12 +684,25 @@ describe('useWorkflowStore', () => {
|
||||
describe('NodeLocatorId conversions', () => {
|
||||
beforeEach(() => {
|
||||
// Setup mock graph structure with subgraphs
|
||||
const mockRootGraph = {
|
||||
_nodes: [] as unknown[],
|
||||
nodes: [] as unknown[],
|
||||
subgraphs: new Map(),
|
||||
getNodeById: (id: string | number) => {
|
||||
if (String(id) === '123') return mockNode
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const mockSubgraph = {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
rootGraph: null as any,
|
||||
rootGraph: mockRootGraph as LGraph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
}
|
||||
nodes: [],
|
||||
clear() {
|
||||
return undefined
|
||||
}
|
||||
} as Partial<Subgraph> as Subgraph
|
||||
|
||||
const mockNode = {
|
||||
id: 123,
|
||||
@@ -761,21 +710,13 @@ describe('useWorkflowStore', () => {
|
||||
subgraph: mockSubgraph
|
||||
}
|
||||
|
||||
const mockRootGraph = {
|
||||
_nodes: [mockNode],
|
||||
nodes: [mockNode],
|
||||
subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]),
|
||||
getNodeById: (id: string | number) => {
|
||||
if (String(id) === '123') return mockNode
|
||||
return null
|
||||
}
|
||||
}
|
||||
mockRootGraph._nodes = [mockNode]
|
||||
mockRootGraph.nodes = [mockNode]
|
||||
mockRootGraph.subgraphs = new Map([[mockSubgraph.id, mockSubgraph]])
|
||||
|
||||
mockSubgraph.rootGraph = mockRootGraph as any
|
||||
|
||||
vi.mocked(comfyApp).rootGraph = mockRootGraph as any
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
|
||||
store.activeSubgraph = mockSubgraph as any
|
||||
vi.mocked(comfyApp).rootGraph = mockRootGraph as LGraph
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph
|
||||
store.activeSubgraph = mockSubgraph
|
||||
})
|
||||
|
||||
describe('nodeIdToNodeLocatorId', () => {
|
||||
@@ -792,8 +733,12 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
it('should use provided subgraph instead of active one', () => {
|
||||
const customSubgraph = {
|
||||
id: 'custom-uuid-1234-5678-90ab-cdef12345678'
|
||||
} as any
|
||||
id: 'custom-uuid-1234-5678-90ab-cdef12345678',
|
||||
rootGraph: undefined! as LGraph,
|
||||
_nodes: [],
|
||||
nodes: [],
|
||||
clear: vi.fn()
|
||||
} as Partial<Subgraph> as Subgraph
|
||||
const result = store.nodeIdToNodeLocatorId(789, customSubgraph)
|
||||
expect(result).toBe('custom-uuid-1234-5678-90ab-cdef12345678:789')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { until, useAsyncState } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
@@ -531,48 +532,72 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
workflow.isPersisted && !workflow.path.startsWith('subgraphs/')
|
||||
)
|
||||
)
|
||||
const syncWorkflows = async (dir: string = '') => {
|
||||
await syncEntities(
|
||||
dir ? 'workflows/' + dir : 'workflows',
|
||||
workflowLookup.value,
|
||||
(file) =>
|
||||
new ComfyWorkflow({
|
||||
path: file.path,
|
||||
modified: file.modified,
|
||||
size: file.size
|
||||
}),
|
||||
(existingWorkflow, file) => {
|
||||
const isActiveWorkflow =
|
||||
activeWorkflow.value?.path === existingWorkflow.path
|
||||
|
||||
const nextLastModified = Math.max(
|
||||
existingWorkflow.lastModified,
|
||||
file.modified
|
||||
)
|
||||
const {
|
||||
isReady: isSyncReady,
|
||||
isLoading: isSyncLoading,
|
||||
execute: executeSyncWorkflows
|
||||
} = useAsyncState(
|
||||
async (dir: string = '') => {
|
||||
await syncEntities(
|
||||
dir ? 'workflows/' + dir : 'workflows',
|
||||
workflowLookup.value,
|
||||
(file) =>
|
||||
new ComfyWorkflow({
|
||||
path: file.path,
|
||||
modified: file.modified,
|
||||
size: file.size
|
||||
}),
|
||||
(existingWorkflow, file) => {
|
||||
const isActiveWorkflow =
|
||||
activeWorkflow.value?.path === existingWorkflow.path
|
||||
|
||||
const isMetadataUnchanged =
|
||||
nextLastModified === existingWorkflow.lastModified &&
|
||||
file.size === existingWorkflow.size
|
||||
const nextLastModified = Math.max(
|
||||
existingWorkflow.lastModified,
|
||||
file.modified
|
||||
)
|
||||
|
||||
if (!isMetadataUnchanged) {
|
||||
existingWorkflow.lastModified = nextLastModified
|
||||
existingWorkflow.size = file.size
|
||||
}
|
||||
const isMetadataUnchanged =
|
||||
nextLastModified === existingWorkflow.lastModified &&
|
||||
file.size === existingWorkflow.size
|
||||
|
||||
// Never unload the active workflow - it may contain unsaved in-memory edits.
|
||||
if (isActiveWorkflow) {
|
||||
return
|
||||
}
|
||||
if (!isMetadataUnchanged) {
|
||||
existingWorkflow.lastModified = nextLastModified
|
||||
existingWorkflow.size = file.size
|
||||
}
|
||||
|
||||
// If nothing changed, keep any loaded content cached.
|
||||
if (isMetadataUnchanged) {
|
||||
return
|
||||
}
|
||||
// Never unload the active workflow - it may contain unsaved in-memory edits.
|
||||
if (isActiveWorkflow) {
|
||||
return
|
||||
}
|
||||
|
||||
existingWorkflow.unload()
|
||||
},
|
||||
/* exclude */ (workflow) => workflow.isTemporary
|
||||
)
|
||||
// If nothing changed, keep any loaded content cached.
|
||||
if (isMetadataUnchanged) {
|
||||
return
|
||||
}
|
||||
|
||||
existingWorkflow.unload()
|
||||
},
|
||||
/* exclude */ (workflow) => workflow.isTemporary
|
||||
)
|
||||
},
|
||||
undefined,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
async function syncWorkflows(dir: string = '') {
|
||||
return executeSyncWorkflows(0, dir)
|
||||
}
|
||||
|
||||
async function loadWorkflows(): Promise<void> {
|
||||
if (isSyncReady.value) return
|
||||
|
||||
if (isSyncLoading.value) {
|
||||
await until(isSyncLoading).toBe(false)
|
||||
return
|
||||
}
|
||||
|
||||
await syncWorkflows()
|
||||
}
|
||||
|
||||
const bookmarkStore = useWorkflowBookmarkStore()
|
||||
@@ -884,6 +909,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
modifiedWorkflows,
|
||||
getWorkflowByPath,
|
||||
syncWorkflows,
|
||||
loadWorkflows,
|
||||
|
||||
isSubgraphActive,
|
||||
activeSubgraph,
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('useWorkflowAutoSave', () => {
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
const serviceInstance = (useWorkflowService as any).mock.results[0].value
|
||||
const serviceInstance = vi.mocked(useWorkflowService).mock.results[0].value
|
||||
expect(serviceInstance.saveWorkflow).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow
|
||||
)
|
||||
@@ -85,7 +85,7 @@ describe('useWorkflowAutoSave', () => {
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
const serviceInstance = (useWorkflowService as any).mock.results[0].value
|
||||
const serviceInstance = vi.mocked(useWorkflowService).mock.results[0].value
|
||||
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalledWith(
|
||||
mockActiveWorkflow
|
||||
)
|
||||
@@ -106,7 +106,7 @@ describe('useWorkflowAutoSave', () => {
|
||||
|
||||
vi.advanceTimersByTime(mockAutoSaveDelay)
|
||||
|
||||
const serviceInstance = (useWorkflowService as any).mock.results[0].value
|
||||
const serviceInstance = vi.mocked(useWorkflowService).mock.results[0].value
|
||||
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('useWorkflowAutoSave', () => {
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
const serviceInstance = (useWorkflowService as any).mock.results[0].value
|
||||
const serviceInstance = vi.mocked(useWorkflowService).mock.results[0].value
|
||||
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
@@ -146,14 +146,15 @@ describe('useWorkflowAutoSave', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const serviceInstance = (useWorkflowService as any).mock.results[0].value
|
||||
const graphChangedCallback = (api.addEventListener as any).mock.calls[0][1]
|
||||
const serviceInstance = vi.mocked(useWorkflowService).mock.results[0].value
|
||||
const graphChangedCallback = vi.mocked(api.addEventListener).mock
|
||||
.calls[0][1]
|
||||
|
||||
graphChangedCallback()
|
||||
graphChangedCallback?.({} as Parameters<typeof graphChangedCallback>[0])
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
graphChangedCallback()
|
||||
graphChangedCallback?.({} as Parameters<typeof graphChangedCallback>[0])
|
||||
|
||||
vi.advanceTimersByTime(1999)
|
||||
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalled()
|
||||
@@ -180,7 +181,8 @@ describe('useWorkflowAutoSave', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const serviceInstance = (useWorkflowService as any).mock.results[0].value
|
||||
const serviceInstance =
|
||||
vi.mocked(useWorkflowService).mock.results[0].value
|
||||
serviceInstance.saveWorkflow.mockRejectedValue(new Error('Test Error'))
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
@@ -208,7 +210,7 @@ describe('useWorkflowAutoSave', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const serviceInstance = (useWorkflowService as any).mock.results[0].value
|
||||
const serviceInstance = vi.mocked(useWorkflowService).mock.results[0].value
|
||||
let resolveSave: () => void
|
||||
const firstSavePromise = new Promise<void>((resolve) => {
|
||||
resolveSave = resolve
|
||||
@@ -218,8 +220,9 @@ describe('useWorkflowAutoSave', () => {
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
const graphChangedCallback = (api.addEventListener as any).mock.calls[0][1]
|
||||
graphChangedCallback()
|
||||
const graphChangedCallback = vi.mocked(api.addEventListener).mock
|
||||
.calls[0][1]
|
||||
graphChangedCallback?.({} as Parameters<typeof graphChangedCallback>[0])
|
||||
|
||||
resolveSave!()
|
||||
await Promise.resolve()
|
||||
@@ -259,14 +262,15 @@ describe('useWorkflowAutoSave', () => {
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
const serviceInstance = (useWorkflowService as any).mock.results[0].value
|
||||
const serviceInstance = vi.mocked(useWorkflowService).mock.results[0].value
|
||||
expect(serviceInstance.saveWorkflow).toHaveBeenCalledTimes(1)
|
||||
serviceInstance.saveWorkflow.mockClear()
|
||||
|
||||
mockAutoSaveDelay = -500
|
||||
|
||||
const graphChangedCallback = (api.addEventListener as any).mock.calls[0][1]
|
||||
graphChangedCallback()
|
||||
const graphChangedCallback = vi.mocked(api.addEventListener).mock
|
||||
.calls[0][1]
|
||||
graphChangedCallback?.({} as Parameters<typeof graphChangedCallback>[0])
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
@@ -288,7 +292,7 @@ describe('useWorkflowAutoSave', () => {
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
const serviceInstance = (useWorkflowService as any).mock.results[0].value
|
||||
const serviceInstance = vi.mocked(useWorkflowService).mock.results[0].value
|
||||
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalledWith(
|
||||
mockActiveWorkflow
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ const preservedQueryMocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
// Mock vue-router
|
||||
let mockQueryParams: Record<string, string | undefined> = {}
|
||||
let mockQueryParams: Record<string, string | string[] | undefined> = {}
|
||||
const mockRouterReplace = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -60,10 +60,10 @@ vi.mock('primevue/usetoast', () => ({
|
||||
// Mock i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: vi.fn((key: string, params?: any) => {
|
||||
t: vi.fn((key: string, params?: unknown) => {
|
||||
if (key === 'g.error') return 'Error'
|
||||
if (key === 'templateWorkflows.error.templateNotFound') {
|
||||
return `Template "${params?.templateName}" not found`
|
||||
return `Template "${(params as { templateName?: string })?.templateName}" not found`
|
||||
}
|
||||
if (key === 'g.errorLoadingTemplate') return 'Failed to load template'
|
||||
return key
|
||||
@@ -152,7 +152,7 @@ describe('useTemplateUrlLoader', () => {
|
||||
|
||||
it('handles array query params correctly', () => {
|
||||
// Vue Router can return string[] for duplicate params
|
||||
mockQueryParams = { template: ['first', 'second'] as any }
|
||||
mockQueryParams = { template: ['first', 'second'] }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
void loadTemplateFromUrl()
|
||||
@@ -333,7 +333,7 @@ describe('useTemplateUrlLoader', () => {
|
||||
// Vue Router can return string[] for duplicate params
|
||||
mockQueryParams = {
|
||||
template: 'flux_simple',
|
||||
mode: ['linear', 'graph'] as any
|
||||
mode: ['linear', 'graph']
|
||||
}
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
@@ -49,8 +49,10 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
type MockWorkflowTemplatesStore = ReturnType<typeof useWorkflowTemplatesStore>
|
||||
|
||||
describe('useTemplateWorkflows', () => {
|
||||
let mockWorkflowTemplatesStore: any
|
||||
let mockWorkflowTemplatesStore: MockWorkflowTemplatesStore
|
||||
|
||||
beforeEach(() => {
|
||||
mockWorkflowTemplatesStore = {
|
||||
@@ -70,7 +72,8 @@ describe('useTemplateWorkflows', () => {
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
sourceModule: 'default',
|
||||
localizedTitle: 'Template 1'
|
||||
localizedTitle: 'Template 1',
|
||||
description: 'Template 1 description'
|
||||
},
|
||||
{
|
||||
name: 'template2',
|
||||
@@ -91,14 +94,15 @@ describe('useTemplateWorkflows', () => {
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
localizedTitle: 'Template 1',
|
||||
localizedDescription: 'A default template'
|
||||
localizedDescription: 'A default template',
|
||||
description: 'Template 1 description'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
} as Partial<MockWorkflowTemplatesStore> as MockWorkflowTemplatesStore
|
||||
|
||||
vi.mocked(useWorkflowTemplatesStore).mockReturnValue(
|
||||
mockWorkflowTemplatesStore
|
||||
@@ -107,7 +111,7 @@ describe('useTemplateWorkflows', () => {
|
||||
// Mock fetch response
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ workflow: 'data' })
|
||||
} as unknown as Response)
|
||||
} as Partial<Response> as Response)
|
||||
})
|
||||
|
||||
it('should load templates from store', async () => {
|
||||
|
||||
@@ -20,18 +20,15 @@ export function useWorkflowValidation() {
|
||||
// Collect all logs in an array
|
||||
const logs: string[] = []
|
||||
// Then validate and fix links if schema validation passed
|
||||
const linkValidation = fixBadLinks(
|
||||
graphData as unknown as ISerialisedGraph,
|
||||
{
|
||||
fix: true,
|
||||
silent,
|
||||
logger: {
|
||||
log: (message: string) => {
|
||||
logs.push(message)
|
||||
}
|
||||
const linkValidation = fixBadLinks(graphData as ISerialisedGraph, {
|
||||
fix: true,
|
||||
silent,
|
||||
logger: {
|
||||
log: (...args: unknown[]) => {
|
||||
logs.push(args.join(' '))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (!silent && logs.length > 0) {
|
||||
toastStore.add({
|
||||
@@ -52,7 +49,7 @@ export function useWorkflowValidation() {
|
||||
}
|
||||
}
|
||||
|
||||
return linkValidation.graph as unknown as ComfyWorkflowJSON
|
||||
return linkValidation.graph
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +77,9 @@ export function useWorkflowValidation() {
|
||||
|
||||
if (validatedGraphData) {
|
||||
try {
|
||||
validatedData = tryFixLinks(validatedGraphData, { silent })
|
||||
validatedData = tryFixLinks(validatedGraphData, {
|
||||
silent
|
||||
}) as ComfyWorkflowJSON
|
||||
} catch (err) {
|
||||
// Link fixer itself is throwing an error
|
||||
console.error(err)
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('useTransformState', () => {
|
||||
beforeEach(() => {
|
||||
transformState.syncWithCanvas({
|
||||
ds: { offset: [0, 0] }
|
||||
} as unknown as LGraphCanvas)
|
||||
} as LGraphCanvas)
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
@@ -62,7 +62,7 @@ describe('useTransformState', () => {
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
expect(camera.x).toBe(100)
|
||||
expect(camera.y).toBe(50)
|
||||
@@ -72,7 +72,7 @@ describe('useTransformState', () => {
|
||||
it('should handle null canvas gracefully', () => {
|
||||
const { syncWithCanvas, camera } = transformState
|
||||
|
||||
syncWithCanvas(null as any)
|
||||
syncWithCanvas(null! as LGraphCanvas)
|
||||
|
||||
// Should remain at initial values
|
||||
expect(camera.x).toBe(0)
|
||||
@@ -84,7 +84,7 @@ describe('useTransformState', () => {
|
||||
const { syncWithCanvas, camera } = transformState
|
||||
const canvasWithoutDs = { canvas: {} }
|
||||
|
||||
syncWithCanvas(canvasWithoutDs as any)
|
||||
syncWithCanvas(canvasWithoutDs as LGraphCanvas)
|
||||
|
||||
// Should remain at initial values
|
||||
expect(camera.x).toBe(0)
|
||||
@@ -99,7 +99,7 @@ describe('useTransformState', () => {
|
||||
mockCanvas.ds.offset = [150, 75]
|
||||
mockCanvas.ds.scale = 0.5
|
||||
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale(0.5) translate(150px, 75px)',
|
||||
@@ -114,7 +114,7 @@ describe('useTransformState', () => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
})
|
||||
|
||||
describe('canvasToScreen', () => {
|
||||
@@ -176,7 +176,7 @@ describe('useTransformState', () => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
})
|
||||
|
||||
it('should calculate correct screen bounds for a node', () => {
|
||||
@@ -201,7 +201,7 @@ describe('useTransformState', () => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [0, 0]
|
||||
mockCanvas.ds.scale = 1
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
})
|
||||
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
@@ -257,14 +257,14 @@ describe('useTransformState', () => {
|
||||
|
||||
// Test with very low zoom
|
||||
mockCanvas.ds.scale = 0.05
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
// Node at edge should still be visible due to increased margin
|
||||
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
|
||||
|
||||
// Test with high zoom
|
||||
mockCanvas.ds.scale = 4
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
// Margin should be tighter
|
||||
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
|
||||
@@ -276,7 +276,7 @@ describe('useTransformState', () => {
|
||||
const mockCanvas = createMockCanvasContext()
|
||||
mockCanvas.ds.offset = [100, 50]
|
||||
mockCanvas.ds.scale = 2
|
||||
transformState.syncWithCanvas(mockCanvas as any)
|
||||
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
})
|
||||
|
||||
it('should calculate viewport bounds in canvas coordinates', () => {
|
||||
@@ -322,7 +322,7 @@ describe('useTransformState', () => {
|
||||
|
||||
// Very small zoom
|
||||
mockCanvas.ds.scale = 0.001
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
const point1 = canvasToScreen({ x: 1000, y: 1000 })
|
||||
expect(point1.x).toBeCloseTo(1)
|
||||
@@ -330,7 +330,7 @@ describe('useTransformState', () => {
|
||||
|
||||
// Very large zoom
|
||||
mockCanvas.ds.scale = 100
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
const point2 = canvasToScreen({ x: 1, y: 1 })
|
||||
expect(point2.x).toBe(100)
|
||||
@@ -343,7 +343,7 @@ describe('useTransformState', () => {
|
||||
|
||||
// Scale of 0 gets converted to 1 by || operator
|
||||
mockCanvas.ds.scale = 0
|
||||
syncWithCanvas(mockCanvas as any)
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
// Should use scale of 1 due to camera.z || 1 in implementation
|
||||
const result = screenToCanvas({ x: 100, y: 100 })
|
||||
|
||||
@@ -1,6 +1,50 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, shallowRef } from 'vue'
|
||||
|
||||
import {
|
||||
createMockCanvas2DContext,
|
||||
createMockMinimapCanvas
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
interface MockNode {
|
||||
id: string
|
||||
pos: number[]
|
||||
size: number[]
|
||||
color?: string
|
||||
constructor?: { color: string }
|
||||
outputs?: { links: string[] }[] | null
|
||||
}
|
||||
|
||||
interface MockGraph {
|
||||
_nodes: MockNode[]
|
||||
links: Record<string, { id: string; target_id: string }>
|
||||
getNodeById: Mock
|
||||
setDirtyCanvas: Mock
|
||||
onNodeAdded: ((node: MockNode) => void) | null
|
||||
onNodeRemoved: ((node: MockNode) => void) | null
|
||||
onConnectionChange: ((node: MockNode) => void) | null
|
||||
}
|
||||
|
||||
interface MockCanvas {
|
||||
graph: MockGraph
|
||||
canvas: {
|
||||
width: number
|
||||
height: number
|
||||
clientWidth: number
|
||||
clientHeight: number
|
||||
}
|
||||
ds: {
|
||||
scale: number
|
||||
offset: [number, number]
|
||||
}
|
||||
setDirty: Mock
|
||||
}
|
||||
|
||||
interface MockContainerElement {
|
||||
getBoundingClientRect: Mock
|
||||
}
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const triggerRAF = async () => {
|
||||
@@ -39,15 +83,15 @@ vi.mock('@vueuse/core', () => {
|
||||
}
|
||||
}),
|
||||
useThrottleFn: vi.fn((callback) => {
|
||||
return (...args: any[]) => {
|
||||
return (...args: unknown[]) => {
|
||||
return callback(...args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let mockCanvas: any
|
||||
let mockGraph: any
|
||||
let moduleMockCanvas: MockCanvas = null!
|
||||
let moduleMockGraph: MockGraph = null!
|
||||
|
||||
const setupMocks = () => {
|
||||
const mockNodes = [
|
||||
@@ -72,7 +116,7 @@ const setupMocks = () => {
|
||||
}
|
||||
]
|
||||
|
||||
mockGraph = {
|
||||
moduleMockGraph = {
|
||||
_nodes: mockNodes,
|
||||
links: {
|
||||
link1: {
|
||||
@@ -87,8 +131,8 @@ const setupMocks = () => {
|
||||
onConnectionChange: null
|
||||
}
|
||||
|
||||
mockCanvas = {
|
||||
graph: mockGraph,
|
||||
moduleMockCanvas = {
|
||||
graph: moduleMockGraph,
|
||||
canvas: {
|
||||
width: 1000,
|
||||
height: 800,
|
||||
@@ -105,8 +149,11 @@ const setupMocks = () => {
|
||||
|
||||
setupMocks()
|
||||
|
||||
const defaultCanvasStore = {
|
||||
canvas: mockCanvas,
|
||||
const defaultCanvasStore: {
|
||||
canvas: MockCanvas | null
|
||||
getCanvas: () => MockCanvas | null
|
||||
} = {
|
||||
canvas: moduleMockCanvas,
|
||||
getCanvas: () => defaultCanvasStore.canvas
|
||||
}
|
||||
|
||||
@@ -142,7 +189,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
graph: mockGraph
|
||||
graph: moduleMockGraph
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -158,16 +205,16 @@ const { useMinimap } =
|
||||
const { api } = await import('@/scripts/api')
|
||||
|
||||
describe('useMinimap', () => {
|
||||
let mockCanvas: any
|
||||
let mockGraph: any
|
||||
let mockCanvasElement: any
|
||||
let mockContainerElement: any
|
||||
let mockContext2D: any
|
||||
let moduleMockCanvasElement: HTMLCanvasElement
|
||||
let mockContainerElement: MockContainerElement
|
||||
let mockContext2D: CanvasRenderingContext2D
|
||||
|
||||
async function createAndInitializeMinimap() {
|
||||
const minimap = useMinimap({
|
||||
containerRefMaybe: shallowRef(mockContainerElement),
|
||||
canvasRefMaybe: shallowRef(mockCanvasElement)
|
||||
containerRefMaybe: shallowRef(
|
||||
mockContainerElement as Partial<HTMLDivElement> as HTMLDivElement
|
||||
),
|
||||
canvasRefMaybe: shallowRef(moduleMockCanvasElement)
|
||||
})
|
||||
await minimap.init()
|
||||
await nextTick()
|
||||
@@ -181,28 +228,15 @@ describe('useMinimap', () => {
|
||||
mockPause.mockClear()
|
||||
mockResume.mockClear()
|
||||
|
||||
mockContext2D = {
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0
|
||||
}
|
||||
mockContext2D = createMockCanvas2DContext()
|
||||
|
||||
mockCanvasElement = {
|
||||
getContext: vi.fn().mockReturnValue(mockContext2D),
|
||||
width: 250,
|
||||
height: 200,
|
||||
clientWidth: 250,
|
||||
clientHeight: 200
|
||||
}
|
||||
moduleMockCanvasElement = createMockMinimapCanvas({
|
||||
getContext: vi
|
||||
.fn()
|
||||
.mockImplementation((contextId) =>
|
||||
contextId === '2d' ? mockContext2D : null
|
||||
) as HTMLCanvasElement['getContext']
|
||||
})
|
||||
|
||||
const mockRect = {
|
||||
left: 100,
|
||||
@@ -241,7 +275,7 @@ describe('useMinimap', () => {
|
||||
}
|
||||
]
|
||||
|
||||
mockGraph = {
|
||||
moduleMockGraph = {
|
||||
_nodes: mockNodes,
|
||||
links: {
|
||||
link1: {
|
||||
@@ -256,8 +290,8 @@ describe('useMinimap', () => {
|
||||
onConnectionChange: null
|
||||
}
|
||||
|
||||
mockCanvas = {
|
||||
graph: mockGraph,
|
||||
moduleMockCanvas = {
|
||||
graph: moduleMockGraph,
|
||||
canvas: {
|
||||
width: 1000,
|
||||
height: 800,
|
||||
@@ -271,7 +305,7 @@ describe('useMinimap', () => {
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
|
||||
defaultCanvasStore.canvas = mockCanvas
|
||||
defaultCanvasStore.canvas = moduleMockCanvas
|
||||
|
||||
defaultSettingStore.get = vi.fn().mockReturnValue(true)
|
||||
defaultSettingStore.set = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -338,9 +372,9 @@ describe('useMinimap', () => {
|
||||
|
||||
await minimap.init()
|
||||
|
||||
expect(mockGraph.onNodeAdded).toBeDefined()
|
||||
expect(mockGraph.onNodeRemoved).toBeDefined()
|
||||
expect(mockGraph.onConnectionChange).toBeDefined()
|
||||
expect(moduleMockGraph.onNodeAdded).toBeDefined()
|
||||
expect(moduleMockGraph.onNodeRemoved).toBeDefined()
|
||||
expect(moduleMockGraph.onConnectionChange).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle visibility from settings', async () => {
|
||||
@@ -377,18 +411,20 @@ describe('useMinimap', () => {
|
||||
onConnectionChange: vi.fn()
|
||||
}
|
||||
|
||||
mockGraph.onNodeAdded = originalCallbacks.onNodeAdded
|
||||
mockGraph.onNodeRemoved = originalCallbacks.onNodeRemoved
|
||||
mockGraph.onConnectionChange = originalCallbacks.onConnectionChange
|
||||
moduleMockGraph.onNodeAdded = originalCallbacks.onNodeAdded
|
||||
moduleMockGraph.onNodeRemoved = originalCallbacks.onNodeRemoved
|
||||
moduleMockGraph.onConnectionChange = originalCallbacks.onConnectionChange
|
||||
|
||||
const minimap = await createAndInitializeMinimap()
|
||||
|
||||
await minimap.init()
|
||||
minimap.destroy()
|
||||
|
||||
expect(mockGraph.onNodeAdded).toBe(originalCallbacks.onNodeAdded)
|
||||
expect(mockGraph.onNodeRemoved).toBe(originalCallbacks.onNodeRemoved)
|
||||
expect(mockGraph.onConnectionChange).toBe(
|
||||
expect(moduleMockGraph.onNodeAdded).toBe(originalCallbacks.onNodeAdded)
|
||||
expect(moduleMockGraph.onNodeRemoved).toBe(
|
||||
originalCallbacks.onNodeRemoved
|
||||
)
|
||||
expect(moduleMockGraph.onConnectionChange).toBe(
|
||||
originalCallbacks.onConnectionChange
|
||||
)
|
||||
})
|
||||
@@ -421,7 +457,7 @@ describe('useMinimap', () => {
|
||||
it('should verify context is obtained during render', async () => {
|
||||
const minimap = await createAndInitializeMinimap()
|
||||
|
||||
const getContextSpy = vi.spyOn(mockCanvasElement, 'getContext')
|
||||
const getContextSpy = vi.spyOn(moduleMockCanvasElement, 'getContext')
|
||||
|
||||
await minimap.init()
|
||||
|
||||
@@ -429,10 +465,12 @@ describe('useMinimap', () => {
|
||||
minimap.renderMinimap()
|
||||
|
||||
// Force a render by triggering a graph change
|
||||
mockGraph._nodes.push({
|
||||
moduleMockGraph._nodes.push({
|
||||
id: 'new-node',
|
||||
pos: [150, 150],
|
||||
size: [100, 50]
|
||||
size: [100, 50],
|
||||
constructor: { color: '#666' },
|
||||
outputs: []
|
||||
})
|
||||
|
||||
// Trigger RAF to process changes
|
||||
@@ -452,15 +490,15 @@ describe('useMinimap', () => {
|
||||
minimap.renderMinimap()
|
||||
|
||||
// Force a render by modifying a node position
|
||||
mockGraph._nodes[0].pos = [50, 50]
|
||||
moduleMockGraph._nodes[0].pos = [50, 50]
|
||||
|
||||
// Trigger RAF to process changes
|
||||
await triggerRAF()
|
||||
await nextTick()
|
||||
|
||||
const renderingOccurred =
|
||||
mockContext2D.clearRect.mock.calls.length > 0 ||
|
||||
mockContext2D.fillRect.mock.calls.length > 0
|
||||
vi.mocked(mockContext2D.clearRect).mock.calls.length > 0 ||
|
||||
vi.mocked(mockContext2D.fillRect).mock.calls.length > 0
|
||||
|
||||
if (!renderingOccurred) {
|
||||
console.log('Minimap visible:', minimap.visible.value)
|
||||
@@ -469,12 +507,15 @@ describe('useMinimap', () => {
|
||||
console.log('Graph exists:', !!defaultCanvasStore.canvas?.graph)
|
||||
console.log(
|
||||
'clearRect calls:',
|
||||
mockContext2D.clearRect.mock.calls.length
|
||||
vi.mocked(mockContext2D.clearRect).mock.calls.length
|
||||
)
|
||||
console.log(
|
||||
'fillRect calls:',
|
||||
vi.mocked(mockContext2D.fillRect).mock.calls.length
|
||||
)
|
||||
console.log('fillRect calls:', mockContext2D.fillRect.mock.calls.length)
|
||||
console.log(
|
||||
'getContext calls:',
|
||||
mockCanvasElement.getContext.mock.calls.length
|
||||
vi.mocked(moduleMockCanvasElement.getContext).mock.calls.length
|
||||
)
|
||||
}
|
||||
|
||||
@@ -482,7 +523,11 @@ describe('useMinimap', () => {
|
||||
})
|
||||
|
||||
it('should not render when context is null', async () => {
|
||||
mockCanvasElement.getContext = vi.fn().mockReturnValue(null)
|
||||
moduleMockCanvasElement = createMockMinimapCanvas({
|
||||
getContext: vi
|
||||
.fn()
|
||||
.mockImplementation(() => null) as HTMLCanvasElement['getContext']
|
||||
})
|
||||
|
||||
const minimap = await createAndInitializeMinimap()
|
||||
|
||||
@@ -491,12 +536,18 @@ describe('useMinimap', () => {
|
||||
|
||||
expect(mockContext2D.clearRect).not.toHaveBeenCalled()
|
||||
|
||||
mockCanvasElement.getContext = vi.fn().mockReturnValue(mockContext2D)
|
||||
moduleMockCanvasElement = createMockMinimapCanvas({
|
||||
getContext: vi
|
||||
.fn()
|
||||
.mockImplementation((contextId) =>
|
||||
contextId === '2d' ? mockContext2D : null
|
||||
) as HTMLCanvasElement['getContext']
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty graph', async () => {
|
||||
const originalNodes = [...mockGraph._nodes]
|
||||
mockGraph._nodes = []
|
||||
const originalNodes = [...moduleMockGraph._nodes]
|
||||
moduleMockGraph._nodes = []
|
||||
|
||||
const minimap = await createAndInitializeMinimap()
|
||||
|
||||
@@ -513,7 +564,7 @@ describe('useMinimap', () => {
|
||||
// The key test is that it doesn't crash and properly initializes
|
||||
expect(mockContext2D.clearRect).toHaveBeenCalled()
|
||||
|
||||
mockGraph._nodes = originalNodes
|
||||
moduleMockGraph._nodes = originalNodes
|
||||
})
|
||||
})
|
||||
|
||||
@@ -530,7 +581,7 @@ describe('useMinimap', () => {
|
||||
minimap.handlePointerDown(pointerEvent)
|
||||
|
||||
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(moduleMockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should handle pointer move while dragging (mouse)', async () => {
|
||||
@@ -550,8 +601,8 @@ describe('useMinimap', () => {
|
||||
})
|
||||
minimap.handlePointerMove(pointerMoveEvent)
|
||||
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(mockCanvas.ds.offset).toBeDefined()
|
||||
expect(moduleMockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(moduleMockCanvas.ds.offset).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle pointer up to stop dragging (mouse)', async () => {
|
||||
@@ -566,7 +617,7 @@ describe('useMinimap', () => {
|
||||
|
||||
minimap.handlePointerUp()
|
||||
|
||||
mockCanvas.setDirty.mockClear()
|
||||
moduleMockCanvas.setDirty.mockClear()
|
||||
|
||||
const pointerMoveEvent = new PointerEvent('pointermove', {
|
||||
clientX: 200,
|
||||
@@ -575,7 +626,7 @@ describe('useMinimap', () => {
|
||||
})
|
||||
minimap.handlePointerMove(pointerMoveEvent)
|
||||
|
||||
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(moduleMockCanvas.setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle pointer down and start dragging (touch)', async () => {
|
||||
@@ -590,7 +641,7 @@ describe('useMinimap', () => {
|
||||
minimap.handlePointerDown(pointerEvent)
|
||||
|
||||
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(moduleMockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should handle pointer move while dragging (touch)', async () => {
|
||||
@@ -610,8 +661,8 @@ describe('useMinimap', () => {
|
||||
})
|
||||
minimap.handlePointerMove(pointerMoveEvent)
|
||||
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(mockCanvas.ds.offset).toBeDefined()
|
||||
expect(moduleMockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(moduleMockCanvas.ds.offset).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle pointer move while dragging (pen)', async () => {
|
||||
@@ -631,14 +682,14 @@ describe('useMinimap', () => {
|
||||
})
|
||||
minimap.handlePointerMove(pointerMoveEvent)
|
||||
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(mockCanvas.ds.offset).toBeDefined()
|
||||
expect(moduleMockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(moduleMockCanvas.ds.offset).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not move when not dragging with pointer', async () => {
|
||||
const minimap = await createAndInitializeMinimap()
|
||||
|
||||
mockCanvas.setDirty.mockClear()
|
||||
moduleMockCanvas.setDirty.mockClear()
|
||||
|
||||
const pointerMoveEvent = new PointerEvent('pointermove', {
|
||||
clientX: 200,
|
||||
@@ -647,7 +698,7 @@ describe('useMinimap', () => {
|
||||
})
|
||||
minimap.handlePointerMove(pointerMoveEvent)
|
||||
|
||||
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(moduleMockCanvas.setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle pointer up to stop dragging (touch)', async () => {
|
||||
@@ -662,7 +713,7 @@ describe('useMinimap', () => {
|
||||
|
||||
minimap.handlePointerUp()
|
||||
|
||||
mockCanvas.setDirty.mockClear()
|
||||
moduleMockCanvas.setDirty.mockClear()
|
||||
|
||||
const pointerMoveEvent = new PointerEvent('pointermove', {
|
||||
clientX: 200,
|
||||
@@ -671,7 +722,7 @@ describe('useMinimap', () => {
|
||||
})
|
||||
minimap.handlePointerMove(pointerMoveEvent)
|
||||
|
||||
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(moduleMockCanvas.setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -695,8 +746,8 @@ describe('useMinimap', () => {
|
||||
minimap.handleWheel(wheelEvent)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(mockCanvas.ds.scale).toBeCloseTo(1.1)
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(moduleMockCanvas.ds.scale).toBeCloseTo(1.1)
|
||||
expect(moduleMockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should handle wheel zoom out', async () => {
|
||||
@@ -718,8 +769,8 @@ describe('useMinimap', () => {
|
||||
minimap.handleWheel(wheelEvent)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(mockCanvas.ds.scale).toBeCloseTo(0.9)
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(moduleMockCanvas.ds.scale).toBeCloseTo(0.9)
|
||||
expect(moduleMockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should respect zoom limits', async () => {
|
||||
@@ -727,7 +778,7 @@ describe('useMinimap', () => {
|
||||
|
||||
await minimap.init()
|
||||
|
||||
mockCanvas.ds.scale = 0.1
|
||||
moduleMockCanvas.ds.scale = 0.1
|
||||
|
||||
const wheelEvent = new WheelEvent('wheel', {
|
||||
deltaY: 100,
|
||||
@@ -742,7 +793,7 @@ describe('useMinimap', () => {
|
||||
|
||||
minimap.handleWheel(wheelEvent)
|
||||
|
||||
expect(mockCanvas.ds.scale).toBe(0.1)
|
||||
expect(moduleMockCanvas.ds.scale).toBe(0.1)
|
||||
})
|
||||
|
||||
it('should update container rect if needed', async () => {
|
||||
@@ -790,15 +841,17 @@ describe('useMinimap', () => {
|
||||
|
||||
await minimap.init()
|
||||
|
||||
mockCanvas.canvas.clientWidth = 1200
|
||||
mockCanvas.canvas.clientHeight = 900
|
||||
moduleMockCanvas.canvas.clientWidth = 1200
|
||||
moduleMockCanvas.canvas.clientHeight = 900
|
||||
|
||||
const resizeHandler = (window.addEventListener as any).mock.calls.find(
|
||||
(call: any) => call[0] === 'resize'
|
||||
)?.[1]
|
||||
const resizeHandler = vi
|
||||
.mocked(window.addEventListener)
|
||||
.mock.calls.find((call) => call[0] === 'resize')?.[1] as
|
||||
| EventListener
|
||||
| undefined
|
||||
|
||||
if (resizeHandler) {
|
||||
resizeHandler()
|
||||
resizeHandler(new Event('resize'))
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
@@ -817,12 +870,13 @@ describe('useMinimap', () => {
|
||||
id: 'node3',
|
||||
pos: [300, 200],
|
||||
size: [100, 100],
|
||||
constructor: { color: '#666' }
|
||||
constructor: { color: '#666' },
|
||||
outputs: []
|
||||
}
|
||||
|
||||
mockGraph._nodes.push(newNode)
|
||||
if (mockGraph.onNodeAdded) {
|
||||
mockGraph.onNodeAdded(newNode)
|
||||
moduleMockGraph._nodes.push(newNode)
|
||||
if (moduleMockGraph.onNodeAdded) {
|
||||
moduleMockGraph.onNodeAdded(newNode)
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
@@ -833,11 +887,11 @@ describe('useMinimap', () => {
|
||||
|
||||
await minimap.init()
|
||||
|
||||
const removedNode = mockGraph._nodes[0]
|
||||
mockGraph._nodes.splice(0, 1)
|
||||
const removedNode = moduleMockGraph._nodes[0]
|
||||
moduleMockGraph._nodes.splice(0, 1)
|
||||
|
||||
if (mockGraph.onNodeRemoved) {
|
||||
mockGraph.onNodeRemoved(removedNode)
|
||||
if (moduleMockGraph.onNodeRemoved) {
|
||||
moduleMockGraph.onNodeRemoved(removedNode)
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
@@ -848,8 +902,8 @@ describe('useMinimap', () => {
|
||||
|
||||
await minimap.init()
|
||||
|
||||
if (mockGraph.onConnectionChange) {
|
||||
mockGraph.onConnectionChange(mockGraph._nodes[0])
|
||||
if (moduleMockGraph.onConnectionChange) {
|
||||
moduleMockGraph.onConnectionChange(moduleMockGraph._nodes[0])
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
@@ -870,7 +924,7 @@ describe('useMinimap', () => {
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing node outputs', async () => {
|
||||
mockGraph._nodes[0].outputs = null
|
||||
moduleMockGraph._nodes[0].outputs = null
|
||||
const minimap = await createAndInitializeMinimap()
|
||||
|
||||
await expect(minimap.init()).resolves.not.toThrow()
|
||||
@@ -878,8 +932,8 @@ describe('useMinimap', () => {
|
||||
})
|
||||
|
||||
it('should handle invalid link references', async () => {
|
||||
mockGraph.links.link1.target_id = 'invalid-node'
|
||||
mockGraph.getNodeById.mockReturnValue(null)
|
||||
moduleMockGraph.links.link1.target_id = 'invalid-node'
|
||||
moduleMockGraph.getNodeById.mockReturnValue(null)
|
||||
|
||||
const minimap = await createAndInitializeMinimap()
|
||||
|
||||
@@ -898,16 +952,13 @@ describe('useMinimap', () => {
|
||||
})
|
||||
|
||||
it('should handle nodes without color', async () => {
|
||||
mockGraph._nodes[0].color = undefined
|
||||
moduleMockGraph._nodes[0].color = undefined
|
||||
|
||||
const minimap = await createAndInitializeMinimap()
|
||||
|
||||
await minimap.init()
|
||||
|
||||
const renderMinimap = (minimap as any).renderMinimap
|
||||
if (renderMinimap) {
|
||||
renderMinimap()
|
||||
}
|
||||
minimap.renderMinimap()
|
||||
|
||||
expect(mockContext2D.fillRect).toHaveBeenCalled()
|
||||
expect(mockContext2D.fillStyle).toBeDefined()
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMinimapGraph } from '@/renderer/extensions/minimap/composables/useMinimapGraph'
|
||||
import { api } from '@/scripts/api'
|
||||
import {
|
||||
createMockLGraph,
|
||||
createMockLGraphNode,
|
||||
createMockLLink,
|
||||
createMockLinks
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useThrottleFn: vi.fn((fn) => fn)
|
||||
@@ -24,23 +31,23 @@ describe('useMinimapGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockGraph = {
|
||||
mockGraph = createMockLGraph({
|
||||
id: 'test-graph-123',
|
||||
_nodes: [
|
||||
{ id: '1', pos: [100, 100], size: [150, 80] },
|
||||
{ id: '2', pos: [300, 200], size: [120, 60] }
|
||||
createMockLGraphNode({ id: '1', pos: [100, 100], size: [150, 80] }),
|
||||
createMockLGraphNode({ id: '2', pos: [300, 200], size: [120, 60] })
|
||||
],
|
||||
links: { link1: { id: 'link1' } },
|
||||
links: createMockLinks([createMockLLink({ id: 1 })]),
|
||||
onNodeAdded: vi.fn(),
|
||||
onNodeRemoved: vi.fn(),
|
||||
onConnectionChange: vi.fn()
|
||||
} as any
|
||||
})
|
||||
|
||||
onGraphChangedMock = vi.fn()
|
||||
})
|
||||
|
||||
it('should initialize with empty state', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
expect(graphManager.updateFlags.value).toEqual({
|
||||
@@ -52,7 +59,7 @@ describe('useMinimapGraph', () => {
|
||||
})
|
||||
|
||||
it('should setup event listeners on init', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.init()
|
||||
@@ -72,7 +79,7 @@ describe('useMinimapGraph', () => {
|
||||
mockGraph.onNodeRemoved = originalOnNodeRemoved
|
||||
mockGraph.onConnectionChange = originalOnConnectionChange
|
||||
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.setupEventListeners()
|
||||
@@ -91,7 +98,7 @@ describe('useMinimapGraph', () => {
|
||||
})
|
||||
|
||||
it('should prevent duplicate event listener setup', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// Store original callbacks for comparison
|
||||
@@ -127,7 +134,7 @@ describe('useMinimapGraph', () => {
|
||||
mockGraph.onNodeRemoved = originalOnNodeRemoved
|
||||
mockGraph.onConnectionChange = originalOnConnectionChange
|
||||
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.setupEventListeners()
|
||||
@@ -140,24 +147,14 @@ describe('useMinimapGraph', () => {
|
||||
})
|
||||
|
||||
it('should handle cleanup for never-setup graph', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.cleanupEventListeners()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Attempted to cleanup event listeners for graph that was never set up'
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
expect(() => graphManager.cleanupEventListeners()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should detect node position changes', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// First check - cache initial state
|
||||
@@ -177,14 +174,18 @@ describe('useMinimapGraph', () => {
|
||||
})
|
||||
|
||||
it('should detect node count changes', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// Cache initial state
|
||||
graphManager.checkForChanges()
|
||||
|
||||
// Add a node
|
||||
mockGraph._nodes.push({ id: '3', pos: [400, 300], size: [100, 50] } as any)
|
||||
mockGraph._nodes.push({
|
||||
id: '3',
|
||||
pos: [400, 300],
|
||||
size: [100, 50]
|
||||
} as Partial<LGraphNode> as LGraphNode)
|
||||
|
||||
const hasChanges = graphManager.checkForChanges()
|
||||
expect(hasChanges).toBe(true)
|
||||
@@ -193,17 +194,17 @@ describe('useMinimapGraph', () => {
|
||||
})
|
||||
|
||||
it('should detect connection changes', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// Cache initial state
|
||||
graphManager.checkForChanges()
|
||||
|
||||
// Change connections
|
||||
mockGraph.links = new Map([
|
||||
[1, { id: 1 }],
|
||||
[2, { id: 2 }]
|
||||
]) as any
|
||||
mockGraph.links = createMockLinks([
|
||||
createMockLLink({ id: 1 }),
|
||||
createMockLLink({ id: 2 })
|
||||
])
|
||||
|
||||
const hasChanges = graphManager.checkForChanges()
|
||||
expect(hasChanges).toBe(true)
|
||||
@@ -214,7 +215,7 @@ describe('useMinimapGraph', () => {
|
||||
const originalOnNodeRemoved = vi.fn()
|
||||
mockGraph.onNodeRemoved = originalOnNodeRemoved
|
||||
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.setupEventListeners()
|
||||
@@ -227,7 +228,7 @@ describe('useMinimapGraph', () => {
|
||||
})
|
||||
|
||||
it('should destroy properly', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.init()
|
||||
@@ -241,7 +242,7 @@ describe('useMinimapGraph', () => {
|
||||
})
|
||||
|
||||
it('should clear cache', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// Populate cache
|
||||
@@ -256,7 +257,7 @@ describe('useMinimapGraph', () => {
|
||||
})
|
||||
|
||||
it('should handle null graph gracefully', () => {
|
||||
const graphRef = ref(null as any)
|
||||
const graphRef = ref(null) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
expect(() => graphManager.setupEventListeners()).not.toThrow()
|
||||
@@ -265,7 +266,7 @@ describe('useMinimapGraph', () => {
|
||||
})
|
||||
|
||||
it('should clean up removed nodes from cache', () => {
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
// Cache initial state
|
||||
@@ -283,7 +284,7 @@ describe('useMinimapGraph', () => {
|
||||
const throttledFn = vi.fn()
|
||||
vi.mocked(useThrottleFn).mockReturnValue(throttledFn)
|
||||
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
|
||||
|
||||
graphManager.setupEventListeners()
|
||||
|
||||
@@ -102,9 +102,7 @@ export function useMinimapGraph(
|
||||
|
||||
const originalCallbacks = originalCallbacksMap.get(g.id)
|
||||
if (!originalCallbacks) {
|
||||
console.error(
|
||||
'Attempted to cleanup event listeners for graph that was never set up'
|
||||
)
|
||||
// Graph was never set up (e.g., minimap destroyed before init) - nothing to clean up
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useMinimapInteraction } from '@/renderer/extensions/minimap/composables/useMinimapInteraction'
|
||||
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
|
||||
@@ -19,7 +20,7 @@ describe('useMinimapInteraction', () => {
|
||||
width: 250,
|
||||
height: 200
|
||||
})
|
||||
} as any
|
||||
} as Partial<HTMLDivElement> as HTMLDivElement
|
||||
|
||||
mockCanvas = {
|
||||
ds: {
|
||||
@@ -27,7 +28,7 @@ describe('useMinimapInteraction', () => {
|
||||
offset: [0, 0]
|
||||
},
|
||||
setDirty: vi.fn()
|
||||
} as any
|
||||
} as Partial<MinimapCanvas> as MinimapCanvas
|
||||
|
||||
centerViewOnMock = vi.fn<(worldX: number, worldY: number) => void>()
|
||||
})
|
||||
@@ -36,7 +37,7 @@ describe('useMinimapInteraction', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
@@ -61,7 +62,7 @@ describe('useMinimapInteraction', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
@@ -89,7 +90,7 @@ describe('useMinimapInteraction', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
@@ -117,7 +118,7 @@ describe('useMinimapInteraction', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
@@ -163,7 +164,7 @@ describe('useMinimapInteraction', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
@@ -189,7 +190,7 @@ describe('useMinimapInteraction', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
@@ -220,7 +221,7 @@ describe('useMinimapInteraction', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
@@ -250,7 +251,7 @@ describe('useMinimapInteraction', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
@@ -283,7 +284,7 @@ describe('useMinimapInteraction', () => {
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
@@ -306,7 +307,7 @@ describe('useMinimapInteraction', () => {
|
||||
const containerRef = ref(mockContainer)
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
|
||||
const scaleRef = ref(0.5)
|
||||
const canvasRef = ref(null as any)
|
||||
const canvasRef = ref(null) as Ref<MinimapCanvas | null>
|
||||
|
||||
const interaction = useMinimapInteraction(
|
||||
containerRef,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMinimapRenderer } from '@/renderer/extensions/minimap/composables/useMinimapRenderer'
|
||||
@@ -20,20 +21,20 @@ describe('useMinimapRenderer', () => {
|
||||
|
||||
mockContext = {
|
||||
clearRect: vi.fn()
|
||||
} as any
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
|
||||
mockCanvas = {
|
||||
getContext: vi.fn().mockReturnValue(mockContext)
|
||||
} as any
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
|
||||
mockGraph = {
|
||||
_nodes: [{ id: '1', pos: [0, 0], size: [100, 100] }]
|
||||
} as any
|
||||
} as Partial<LGraph> as LGraph
|
||||
})
|
||||
|
||||
it('should initialize with full redraw needed', () => {
|
||||
const canvasRef = shallowRef<HTMLCanvasElement | null>(mockCanvas)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
@@ -66,9 +67,9 @@ describe('useMinimapRenderer', () => {
|
||||
})
|
||||
|
||||
it('should handle empty graph with fast path', () => {
|
||||
const emptyGraph = { _nodes: [] } as any
|
||||
const emptyGraph = { _nodes: [] } as Partial<LGraph> as LGraph
|
||||
const canvasRef = ref(mockCanvas)
|
||||
const graphRef = ref(emptyGraph)
|
||||
const graphRef = ref(emptyGraph) as Ref<LGraph | null>
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
@@ -106,7 +107,7 @@ describe('useMinimapRenderer', () => {
|
||||
const { renderMinimapToCanvas } =
|
||||
await import('@/renderer/extensions/minimap/minimapCanvasRenderer')
|
||||
const canvasRef = ref(mockCanvas)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
@@ -153,7 +154,7 @@ describe('useMinimapRenderer', () => {
|
||||
const updateViewport = vi.fn()
|
||||
|
||||
const canvasRef = ref(mockCanvas)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
@@ -192,7 +193,7 @@ describe('useMinimapRenderer', () => {
|
||||
|
||||
it('should force full redraw when requested', () => {
|
||||
const canvasRef = ref(mockCanvas)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
@@ -231,7 +232,7 @@ describe('useMinimapRenderer', () => {
|
||||
|
||||
it('should handle null canvas gracefully', () => {
|
||||
const canvasRef = shallowRef<HTMLCanvasElement | null>(null)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
|
||||
const scaleRef = ref(1)
|
||||
const updateFlagsRef = ref<UpdateFlags>({
|
||||
|
||||
@@ -3,10 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useMinimapSettings } from '@/renderer/extensions/minimap/composables/useMinimapSettings'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
type MockSettingStore = ReturnType<typeof useSettingStore>
|
||||
|
||||
const mockUseColorPaletteStore = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/settings/settingStore')
|
||||
vi.mock('@/stores/workspace/colorPaletteStore')
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: mockUseColorPaletteStore
|
||||
}))
|
||||
|
||||
describe('useMinimapSettings', () => {
|
||||
beforeEach(() => {
|
||||
@@ -17,7 +22,7 @@ describe('useMinimapSettings', () => {
|
||||
it('should return all minimap settings as computed refs', () => {
|
||||
const mockSettingStore = {
|
||||
get: vi.fn((key: string) => {
|
||||
const settings: Record<string, any> = {
|
||||
const settings: Record<string, unknown> = {
|
||||
'Comfy.Minimap.NodeColors': true,
|
||||
'Comfy.Minimap.ShowLinks': false,
|
||||
'Comfy.Minimap.ShowGroups': true,
|
||||
@@ -28,10 +33,17 @@ describe('useMinimapSettings', () => {
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue({
|
||||
completedActivePalette: { light_theme: false }
|
||||
} as any)
|
||||
vi.mocked(useSettingStore).mockReturnValue(
|
||||
mockSettingStore as Partial<MockSettingStore> as MockSettingStore
|
||||
)
|
||||
mockUseColorPaletteStore.mockReturnValue({
|
||||
completedActivePalette: {
|
||||
id: 'test',
|
||||
name: 'Test Palette',
|
||||
colors: {},
|
||||
light_theme: false
|
||||
}
|
||||
})
|
||||
|
||||
const settings = useMinimapSettings()
|
||||
|
||||
@@ -44,13 +56,18 @@ describe('useMinimapSettings', () => {
|
||||
|
||||
it('should generate container styles based on theme', () => {
|
||||
const mockColorPaletteStore = {
|
||||
completedActivePalette: { light_theme: false }
|
||||
completedActivePalette: {
|
||||
id: 'test',
|
||||
name: 'Test Palette',
|
||||
colors: {},
|
||||
light_theme: false
|
||||
}
|
||||
}
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue(
|
||||
mockColorPaletteStore as any
|
||||
)
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
get: vi.fn()
|
||||
} as Partial<MockSettingStore> as MockSettingStore)
|
||||
mockUseColorPaletteStore.mockReturnValue(mockColorPaletteStore)
|
||||
|
||||
const settings = useMinimapSettings()
|
||||
const styles = settings.containerStyles.value
|
||||
@@ -63,13 +80,18 @@ describe('useMinimapSettings', () => {
|
||||
|
||||
it('should generate light theme container styles', () => {
|
||||
const mockColorPaletteStore = {
|
||||
completedActivePalette: { light_theme: true }
|
||||
completedActivePalette: {
|
||||
id: 'test',
|
||||
name: 'Test Palette',
|
||||
colors: {},
|
||||
light_theme: true
|
||||
}
|
||||
}
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue(
|
||||
mockColorPaletteStore as any
|
||||
)
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
get: vi.fn()
|
||||
} as Partial<MockSettingStore> as MockSettingStore)
|
||||
mockUseColorPaletteStore.mockReturnValue(mockColorPaletteStore)
|
||||
|
||||
const settings = useMinimapSettings()
|
||||
const styles = settings.containerStyles.value
|
||||
@@ -82,13 +104,18 @@ describe('useMinimapSettings', () => {
|
||||
|
||||
it('should generate panel styles based on theme', () => {
|
||||
const mockColorPaletteStore = {
|
||||
completedActivePalette: { light_theme: false }
|
||||
completedActivePalette: {
|
||||
id: 'test',
|
||||
name: 'Test Palette',
|
||||
colors: {},
|
||||
light_theme: false
|
||||
}
|
||||
}
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue(
|
||||
mockColorPaletteStore as any
|
||||
)
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
get: vi.fn()
|
||||
} as Partial<MockSettingStore> as MockSettingStore)
|
||||
mockUseColorPaletteStore.mockReturnValue(mockColorPaletteStore)
|
||||
|
||||
const settings = useMinimapSettings()
|
||||
const styles = settings.panelStyles.value
|
||||
@@ -107,10 +134,17 @@ describe('useMinimapSettings', () => {
|
||||
})
|
||||
const mockSettingStore = { get: mockGet }
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue({
|
||||
completedActivePalette: { light_theme: false }
|
||||
} as any)
|
||||
vi.mocked(useSettingStore).mockReturnValue(
|
||||
mockSettingStore as Partial<MockSettingStore> as MockSettingStore
|
||||
)
|
||||
mockUseColorPaletteStore.mockReturnValue({
|
||||
completedActivePalette: {
|
||||
id: 'test',
|
||||
name: 'Test Palette',
|
||||
colors: {},
|
||||
light_theme: false
|
||||
}
|
||||
})
|
||||
|
||||
const settings = useMinimapSettings()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
|
||||
@@ -39,7 +40,7 @@ describe('useMinimapViewport', () => {
|
||||
{ pos: [100, 100], size: [150, 80] },
|
||||
{ pos: [300, 200], size: [120, 60] }
|
||||
]
|
||||
} as any
|
||||
} as Partial<LGraph> as LGraph
|
||||
|
||||
vi.mocked(useRafFn, { partial: true }).mockReturnValue({
|
||||
resume: vi.fn(),
|
||||
@@ -48,8 +49,8 @@ describe('useMinimapViewport', () => {
|
||||
})
|
||||
|
||||
it('should initialize with default bounds', () => {
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
@@ -80,8 +81,8 @@ describe('useMinimapViewport', () => {
|
||||
|
||||
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
@@ -97,8 +98,10 @@ describe('useMinimapViewport', () => {
|
||||
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue(null)
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref({ _nodes: [] } as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref({
|
||||
_nodes: []
|
||||
} as Partial<LGraph> as LGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
@@ -115,8 +118,8 @@ describe('useMinimapViewport', () => {
|
||||
})
|
||||
|
||||
it('should update canvas dimensions', () => {
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
@@ -145,8 +148,8 @@ describe('useMinimapViewport', () => {
|
||||
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
|
||||
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
@@ -181,8 +184,8 @@ describe('useMinimapViewport', () => {
|
||||
})
|
||||
|
||||
it('should center view on world coordinates', () => {
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
@@ -209,8 +212,8 @@ describe('useMinimapViewport', () => {
|
||||
pause: stopSyncMock
|
||||
})
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
@@ -222,8 +225,8 @@ describe('useMinimapViewport', () => {
|
||||
})
|
||||
|
||||
it('should handle null canvas gracefully', () => {
|
||||
const canvasRef = ref(null as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const canvasRef = ref(null) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
@@ -250,8 +253,8 @@ describe('useMinimapViewport', () => {
|
||||
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
|
||||
vi.mocked(calculateMinimapScale).mockReturnValue(0.4)
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
@@ -268,8 +271,8 @@ describe('useMinimapViewport', () => {
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const canvasRef = ref(mockCanvas as any)
|
||||
const graphRef = ref(mockGraph as any)
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
|
||||
import type { MinimapRenderContext } from '@/renderer/extensions/minimap/types'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import {
|
||||
createMockLGraph,
|
||||
createMockLGraphNode,
|
||||
createMockLinks,
|
||||
createMockLLink,
|
||||
createMockNodeOutputSlot
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockUseColorPaletteStore = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: mockUseColorPaletteStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore')
|
||||
vi.mock('@/utils/colorUtil', () => ({
|
||||
adjustColor: vi.fn((color: string) => color + '_adjusted')
|
||||
}))
|
||||
@@ -33,15 +43,15 @@ describe('minimapCanvasRenderer', () => {
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1
|
||||
} as any
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
|
||||
mockCanvas = {
|
||||
getContext: vi.fn().mockReturnValue(mockContext)
|
||||
} as any
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
|
||||
mockGraph = {
|
||||
mockGraph = createMockLGraph({
|
||||
_nodes: [
|
||||
{
|
||||
createMockLGraphNode({
|
||||
id: '1',
|
||||
pos: [100, 100],
|
||||
size: [150, 80],
|
||||
@@ -49,8 +59,8 @@ describe('minimapCanvasRenderer', () => {
|
||||
mode: LGraphEventMode.ALWAYS,
|
||||
has_errors: false,
|
||||
outputs: []
|
||||
},
|
||||
{
|
||||
}),
|
||||
createMockLGraphNode({
|
||||
id: '2',
|
||||
pos: [300, 200],
|
||||
size: [120, 60],
|
||||
@@ -58,16 +68,21 @@ describe('minimapCanvasRenderer', () => {
|
||||
mode: LGraphEventMode.BYPASS,
|
||||
has_errors: true,
|
||||
outputs: []
|
||||
}
|
||||
] as unknown as LGraphNode[],
|
||||
})
|
||||
],
|
||||
_groups: [],
|
||||
links: {},
|
||||
links: {} as typeof mockGraph.links,
|
||||
getNodeById: vi.fn()
|
||||
} as any
|
||||
})
|
||||
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue({
|
||||
completedActivePalette: { light_theme: false }
|
||||
} as any)
|
||||
mockUseColorPaletteStore.mockReturnValue({
|
||||
completedActivePalette: {
|
||||
id: 'test',
|
||||
name: 'Test Palette',
|
||||
colors: {},
|
||||
light_theme: false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear canvas and render nodes', () => {
|
||||
@@ -203,7 +218,9 @@ describe('minimapCanvasRenderer', () => {
|
||||
size: [400, 300],
|
||||
color: '#0000FF'
|
||||
}
|
||||
] as any
|
||||
] as Partial<
|
||||
(typeof mockGraph._groups)[number]
|
||||
>[] as typeof mockGraph._groups
|
||||
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
@@ -233,17 +250,17 @@ describe('minimapCanvasRenderer', () => {
|
||||
}
|
||||
|
||||
mockGraph._nodes[0].outputs = [
|
||||
{
|
||||
links: [1]
|
||||
}
|
||||
] as any
|
||||
createMockNodeOutputSlot({
|
||||
name: 'output',
|
||||
type: 'number',
|
||||
links: [1],
|
||||
boundingRect: new Float64Array([0, 0, 10, 10])
|
||||
})
|
||||
]
|
||||
|
||||
// Create a hybrid Map/Object for links as LiteGraph expects
|
||||
const linksMap = new Map([[1, { id: 1, target_id: 2 }]])
|
||||
const links = Object.assign(linksMap, {
|
||||
1: { id: 1, target_id: 2 }
|
||||
})
|
||||
mockGraph.links = links as any
|
||||
mockGraph.links = createMockLinks([
|
||||
createMockLLink({ id: 1, target_id: 2, origin_slot: 0, target_slot: 0 })
|
||||
])
|
||||
|
||||
mockGraph.getNodeById = vi.fn().mockReturnValue(targetNode)
|
||||
|
||||
@@ -275,9 +292,14 @@ describe('minimapCanvasRenderer', () => {
|
||||
})
|
||||
|
||||
it('should handle light theme colors', () => {
|
||||
vi.mocked(useColorPaletteStore).mockReturnValue({
|
||||
completedActivePalette: { light_theme: true }
|
||||
} as any)
|
||||
mockUseColorPaletteStore.mockReturnValue({
|
||||
completedActivePalette: {
|
||||
id: 'test',
|
||||
name: 'Test Palette',
|
||||
colors: {},
|
||||
light_theme: true
|
||||
}
|
||||
})
|
||||
|
||||
const context: MinimapRenderContext = {
|
||||
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -64,12 +65,16 @@ const controlMode = defineModel<ControlOptions>()
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
<Button
|
||||
v-for="option in controlOptions"
|
||||
:key="option.mode"
|
||||
class="flex items-center justify-between py-2 gap-7"
|
||||
as="label"
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
class="flex w-full h-[unset] text-left items-center justify-between py-2 gap-7"
|
||||
:for="option.mode"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0 text-wrap">
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0 bg-secondary-background border border-border-subtle"
|
||||
>
|
||||
@@ -102,11 +107,11 @@ const controlMode = defineModel<ControlOptions>()
|
||||
|
||||
<RadioButton
|
||||
v-model="controlMode"
|
||||
class="flex-shrink-0"
|
||||
class="shrink"
|
||||
:input-id="option.mode"
|
||||
:value="option.mode"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { pushDataLayerEvent } from '@/platform/telemetry/gtm'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
@@ -36,6 +37,16 @@ function getBasePath(): string {
|
||||
|
||||
const basePath = getBasePath()
|
||||
|
||||
function pushPageView(): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
|
||||
pushDataLayerEvent({
|
||||
event: 'page_view',
|
||||
page_location: window.location.href,
|
||||
page_title: document.title
|
||||
})
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
history: isFileProtocol
|
||||
? createWebHashHistory()
|
||||
@@ -93,6 +104,10 @@ installPreservedQueryTracker(router, [
|
||||
}
|
||||
])
|
||||
|
||||
router.afterEach(() => {
|
||||
pushPageView()
|
||||
})
|
||||
|
||||
if (isCloud) {
|
||||
const { flags } = useFeatureFlags()
|
||||
const PUBLIC_ROUTE_NAMES = new Set([
|
||||
|
||||
115
src/stores/bootstrapStore.test.ts
Normal file
115
src/stores/bootstrapStore.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import { useBootstrapStore } from './bootstrapStore'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
getNodeDefs: vi.fn().mockResolvedValue({ TestNode: { name: 'TestNode' } }),
|
||||
getCustomNodesI18n: vi.fn().mockResolvedValue({}),
|
||||
getUserConfig: vi.fn().mockResolvedValue({})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
mergeCustomNodesI18n: vi.fn()
|
||||
}))
|
||||
|
||||
const mockIsSettingsReady = ref(false)
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
load: vi.fn(() => {
|
||||
mockIsSettingsReady.value = true
|
||||
}),
|
||||
get isReady() {
|
||||
return mockIsSettingsReady.value
|
||||
},
|
||||
isLoading: ref(false),
|
||||
error: ref(undefined)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
loadWorkflows: vi.fn(),
|
||||
syncWorkflows: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/userStore', () => ({
|
||||
useUserStore: vi.fn(() => ({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
needsLogin: false
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockIsFirebaseInitialized = ref(false)
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
isInitialized: mockIsFirebaseInitialized
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockDistributionTypes = vi.hoisted(() => ({
|
||||
isCloud: false
|
||||
}))
|
||||
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
|
||||
|
||||
describe('bootstrapStore', () => {
|
||||
beforeEach(() => {
|
||||
mockIsSettingsReady.value = false
|
||||
mockIsFirebaseInitialized.value = false
|
||||
mockDistributionTypes.isCloud = false
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('initializes with all flags false', () => {
|
||||
const store = useBootstrapStore()
|
||||
const settingStore = useSettingStore()
|
||||
expect(settingStore.isReady).toBe(false)
|
||||
expect(store.isI18nReady).toBe(false)
|
||||
})
|
||||
|
||||
it('starts store bootstrap (settings, i18n)', async () => {
|
||||
const store = useBootstrapStore()
|
||||
const settingStore = useSettingStore()
|
||||
void store.startStoreBootstrap()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(settingStore.isReady).toBe(true)
|
||||
expect(store.isI18nReady).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud mode', () => {
|
||||
beforeEach(() => {
|
||||
mockDistributionTypes.isCloud = true
|
||||
})
|
||||
|
||||
it('waits for Firebase auth before loading i18n and settings', async () => {
|
||||
const store = useBootstrapStore()
|
||||
const settingStore = useSettingStore()
|
||||
const bootstrapPromise = store.startStoreBootstrap()
|
||||
|
||||
// Bootstrap is blocked waiting for firebase
|
||||
expect(store.isI18nReady).toBe(false)
|
||||
expect(settingStore.isReady).toBe(false)
|
||||
|
||||
// Unblock by initializing firebase
|
||||
mockIsFirebaseInitialized.value = true
|
||||
await bootstrapPromise
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.isI18nReady).toBe(true)
|
||||
expect(settingStore.isReady).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
54
src/stores/bootstrapStore.ts
Normal file
54
src/stores/bootstrapStore.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { until, useAsyncState } from '@vueuse/core'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
export const useBootstrapStore = defineStore('bootstrap', () => {
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const {
|
||||
isReady: isI18nReady,
|
||||
error: i18nError,
|
||||
execute: loadI18n
|
||||
} = useAsyncState(
|
||||
async () => {
|
||||
const { mergeCustomNodesI18n } = await import('@/i18n')
|
||||
const i18nData = await api.getCustomNodesI18n()
|
||||
mergeCustomNodesI18n(i18nData)
|
||||
},
|
||||
undefined,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
async function startStoreBootstrap() {
|
||||
// Defer settings and workflows if multi-user login is required
|
||||
// (settings API requires authentication in multi-user mode)
|
||||
const userStore = useUserStore()
|
||||
await userStore.initialize()
|
||||
|
||||
if (isCloud) {
|
||||
const { isInitialized } = storeToRefs(useFirebaseAuthStore())
|
||||
await until(isInitialized).toBe(true)
|
||||
}
|
||||
|
||||
// i18n can load without authentication
|
||||
void loadI18n()
|
||||
|
||||
if (!userStore.needsLogin) {
|
||||
void settingStore.load()
|
||||
void workflowStore.loadWorkflows()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isI18nReady,
|
||||
i18nError,
|
||||
startStoreBootstrap
|
||||
}
|
||||
})
|
||||
@@ -26,6 +26,7 @@ import { t } from '@/i18n'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { pushDataLayerEvent as pushDataLayerEventBase } from '@/platform/telemetry/gtm'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
@@ -81,6 +82,42 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
|
||||
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
|
||||
|
||||
function pushDataLayerEvent(event: Record<string, unknown>): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
pushDataLayerEventBase(event)
|
||||
} catch (error) {
|
||||
console.warn('Failed to push data layer event', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function hashSha256(value: string): Promise<string | undefined> {
|
||||
if (typeof crypto === 'undefined' || !crypto.subtle) return
|
||||
if (typeof TextEncoder === 'undefined') return
|
||||
const data = new TextEncoder().encode(value)
|
||||
const hash = await crypto.subtle.digest('SHA-256', data)
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
async function trackSignUp(method: 'email' | 'google' | 'github') {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
const userId = currentUser.value?.uid
|
||||
const hashedUserId = userId ? await hashSha256(userId) : undefined
|
||||
pushDataLayerEvent({
|
||||
event: 'sign_up',
|
||||
method,
|
||||
...(hashedUserId ? { user_id: hashedUserId } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to track sign up', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Providers
|
||||
const googleProvider = new GoogleAuthProvider()
|
||||
googleProvider.addScope('email')
|
||||
@@ -347,6 +384,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
method: 'email',
|
||||
is_new_user: true
|
||||
})
|
||||
await trackSignUp('email')
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -365,6 +403,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
method: 'google',
|
||||
is_new_user: isNewUser
|
||||
})
|
||||
if (isNewUser) {
|
||||
await trackSignUp('google')
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -383,6 +424,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
method: 'github',
|
||||
is_new_user: isNewUser
|
||||
})
|
||||
if (isNewUser) {
|
||||
await trackSignUp('github')
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMagicKeys } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -18,7 +19,7 @@ import { useSidebarTabStore } from './workspace/sidebarTabStore'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const spinner = ref(false)
|
||||
const shiftDown = ref(false)
|
||||
const { shift: shiftDown } = useMagicKeys()
|
||||
/**
|
||||
* Whether the workspace is in focus mode.
|
||||
* When in focus mode, only the graph editor is visible.
|
||||
|
||||
@@ -9,10 +9,12 @@ import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphGroup,
|
||||
LinkNetwork
|
||||
LinkNetwork,
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { vi } from 'vitest'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
|
||||
/**
|
||||
* Creates a mock LGraphNode with minimal required properties
|
||||
@@ -203,3 +205,99 @@ export function createMockFileList(files: File[]): FileList {
|
||||
)
|
||||
return fileList as FileList
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock ChangeTracker for workflow testing
|
||||
* The ChangeTracker requires a proper ComfyWorkflowJSON structure
|
||||
*/
|
||||
export function createMockChangeTracker(
|
||||
overrides: Record<string, unknown> = {}
|
||||
): ChangeTracker {
|
||||
const partial = {
|
||||
activeState: {
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
version: 0.4
|
||||
},
|
||||
undoQueue: [],
|
||||
redoQueue: [],
|
||||
changeCount: 0,
|
||||
reset: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
return partial as Partial<ChangeTracker> as ChangeTracker
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock MinimapCanvas for minimap testing
|
||||
*/
|
||||
export function createMockMinimapCanvas(
|
||||
overrides: Partial<HTMLCanvasElement> = {}
|
||||
): HTMLCanvasElement {
|
||||
const mockGetContext = vi.fn()
|
||||
mockGetContext.mockImplementation((contextId: string) =>
|
||||
contextId === '2d' ? createMockCanvas2DContext() : null
|
||||
)
|
||||
|
||||
const partial: Partial<HTMLCanvasElement> = {
|
||||
width: 200,
|
||||
height: 200,
|
||||
clientWidth: 200,
|
||||
clientHeight: 200,
|
||||
getContext: mockGetContext as HTMLCanvasElement['getContext'],
|
||||
...overrides
|
||||
}
|
||||
return partial as HTMLCanvasElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock CanvasRenderingContext2D for canvas testing
|
||||
*/
|
||||
export function createMockCanvas2DContext(
|
||||
overrides: Partial<CanvasRenderingContext2D> = {}
|
||||
): CanvasRenderingContext2D {
|
||||
const partial: Partial<CanvasRenderingContext2D> = {
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
...overrides
|
||||
}
|
||||
return partial as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
export function createMockLLink(overrides: Partial<LLink> = {}): LLink {
|
||||
const partial: Partial<LLink> = {
|
||||
id: 1,
|
||||
type: '*',
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
_pos: [0, 0],
|
||||
...overrides
|
||||
}
|
||||
return partial as LLink
|
||||
}
|
||||
|
||||
export function createMockLinks(links: LLink[]): LGraph['links'] {
|
||||
const map = new Map<number, LLink>()
|
||||
const record: Record<number, LLink> = {}
|
||||
for (const link of links) {
|
||||
map.set(link.id, link)
|
||||
record[link.id] = link
|
||||
}
|
||||
return Object.assign(map, record) as LGraph['links']
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
@@ -79,7 +79,6 @@ import { useServerConfigStore } from '@/stores/serverConfigStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import LinearView from '@/views/LinearView.vue'
|
||||
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
|
||||
@@ -102,9 +101,6 @@ const { linearMode } = storeToRefs(useCanvasStore())
|
||||
const telemetry = useTelemetry()
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
let hasTrackedLogin = false
|
||||
let visibilityListener: (() => void) | null = null
|
||||
let tabCountInterval: number | null = null
|
||||
let tabCountChannel: BroadcastChannel | null = null
|
||||
|
||||
watch(
|
||||
() => colorPaletteStore.completedActivePalette,
|
||||
@@ -198,15 +194,12 @@ watchEffect(() => {
|
||||
queueStore.maxHistoryItems = settingStore.get('Comfy.Queue.MaxHistoryItems')
|
||||
})
|
||||
|
||||
const init = () => {
|
||||
const coreCommands = useCoreCommands()
|
||||
useCommandStore().registerCommands(coreCommands)
|
||||
useMenuItemStore().registerCoreMenuCommands()
|
||||
useKeybindingService().registerCoreKeybindings()
|
||||
useSidebarTabStore().registerCoreSidebarTabs()
|
||||
useBottomPanelStore().registerCoreBottomPanelTabs()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
}
|
||||
const coreCommands = useCoreCommands()
|
||||
useCommandStore().registerCommands(coreCommands)
|
||||
useMenuItemStore().registerCoreMenuCommands()
|
||||
useKeybindingService().registerCoreKeybindings()
|
||||
useSidebarTabStore().registerCoreSidebarTabs()
|
||||
useBottomPanelStore().registerCoreBottomPanelTabs()
|
||||
|
||||
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
@@ -274,15 +267,15 @@ if (isCloud) {
|
||||
)
|
||||
}
|
||||
|
||||
useEventListener(api, 'status', onStatus)
|
||||
useEventListener(api, 'execution_success', onExecutionSuccess)
|
||||
useEventListener(api, 'reconnecting', onReconnecting)
|
||||
useEventListener(api, 'reconnected', onReconnected)
|
||||
|
||||
onMounted(() => {
|
||||
api.addEventListener('status', onStatus)
|
||||
api.addEventListener('execution_success', onExecutionSuccess)
|
||||
api.addEventListener('reconnecting', onReconnecting)
|
||||
api.addEventListener('reconnected', onReconnected)
|
||||
executionStore.bindExecutionEvents()
|
||||
|
||||
try {
|
||||
init()
|
||||
// Relocate the legacy menu container to the graph canvas container so it is below other elements
|
||||
graphCanvasContainerRef.value?.prepend(app.ui.menuContainer)
|
||||
} catch (e) {
|
||||
@@ -291,27 +284,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
api.removeEventListener('status', onStatus)
|
||||
api.removeEventListener('execution_success', onExecutionSuccess)
|
||||
api.removeEventListener('reconnecting', onReconnecting)
|
||||
api.removeEventListener('reconnected', onReconnected)
|
||||
executionStore.unbindExecutionEvents()
|
||||
|
||||
// Clean up page visibility listener
|
||||
if (visibilityListener) {
|
||||
document.removeEventListener('visibilitychange', visibilityListener)
|
||||
visibilityListener = null
|
||||
}
|
||||
|
||||
// Clean up tab count tracking
|
||||
if (tabCountInterval) {
|
||||
window.clearInterval(tabCountInterval)
|
||||
tabCountInterval = null
|
||||
}
|
||||
if (tabCountChannel) {
|
||||
tabCountChannel.close()
|
||||
tabCountChannel = null
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
|
||||
@@ -337,18 +310,17 @@ const onGraphReady = () => {
|
||||
}
|
||||
|
||||
// Set up page visibility tracking (cloud only)
|
||||
if (isCloud && telemetry && !visibilityListener) {
|
||||
visibilityListener = () => {
|
||||
if (isCloud && telemetry) {
|
||||
useEventListener(document, 'visibilitychange', () => {
|
||||
telemetry.trackPageVisibilityChanged({
|
||||
visibility_state: document.visibilityState as 'visible' | 'hidden'
|
||||
})
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityListener)
|
||||
})
|
||||
}
|
||||
|
||||
// Set up tab count tracking (cloud only)
|
||||
if (isCloud && telemetry && !tabCountInterval) {
|
||||
tabCountChannel = new BroadcastChannel('comfyui-tab-count')
|
||||
if (isCloud && telemetry) {
|
||||
const tabCountChannel = new BroadcastChannel('comfyui-tab-count')
|
||||
const activeTabs = new Map<string, number>()
|
||||
const currentTabId = crypto.randomUUID()
|
||||
|
||||
@@ -363,7 +335,7 @@ const onGraphReady = () => {
|
||||
}
|
||||
|
||||
// 5-minute heartbeat interval
|
||||
tabCountInterval = window.setInterval(() => {
|
||||
useIntervalFn(() => {
|
||||
const now = Date.now()
|
||||
|
||||
// Clean up stale tabs (no heartbeat for 45 seconds)
|
||||
@@ -374,7 +346,7 @@ const onGraphReady = () => {
|
||||
})
|
||||
|
||||
// Broadcast our heartbeat
|
||||
tabCountChannel?.postMessage({
|
||||
tabCountChannel.postMessage({
|
||||
type: 'heartbeat',
|
||||
tabId: currentTabId
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user