Compare commits

..

4 Commits

Author SHA1 Message Date
bymyself
2d6138b09e Revert "fix: add tab semantics to filter picker"
This reverts commit 103aa9e261d01cd7f086d1044cda8c9b871b6cb9.
2026-01-27 18:31:39 -08:00
bymyself
a09ec49770 fix: add tab semantics to filter picker 2026-01-27 18:31:39 -08:00
bymyself
439f26c647 fix: clean up node filter style 2026-01-27 18:31:39 -08:00
bymyself
247a970dc9 update search type badges 2026-01-27 18:31:39 -08:00
141 changed files with 2162 additions and 3765 deletions

View File

@@ -41,7 +41,7 @@ jobs:
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale && pnpm format
run: pnpm locale
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Commit updated locales

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -11,7 +11,6 @@ This guide covers patterns and examples for unit testing utilities, composables,
5. [Mocking Utility Functions](#mocking-utility-functions)
6. [Testing with Debounce and Throttle](#testing-with-debounce-and-throttle)
7. [Mocking Node Definitions](#mocking-node-definitions)
8. [Mocking Composables with Reactive State](#mocking-composables-with-reactive-state)
## Testing Vue Composables with Reactivity
@@ -254,79 +253,3 @@ it('should validate node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})
```
## Mocking Composables with Reactive State
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
### Rules
1. **Define mocks in the factory function** — Create `vi.fn()` and `ref()` instances directly inside `vi.mock()`, not in `beforeEach`
2. **Use singleton pattern** — The factory runs once; all calls to the composable return the same mock object
3. **Access mocks per-test** — Call the composable directly in each test to get the singleton instance rather than storing in a shared variable
4. **Wrap in `vi.mocked()` for type safety** — Use `vi.mocked(service.method).mockResolvedValue(...)` when configuring
5. **Rely on `vi.resetAllMocks()`** — Resets call counts without recreating instances; ref values may need manual reset if mutated
### Pattern
```typescript
// Example from: src/platform/updates/common/releaseStore.test.ts
import { ref } from 'vue'
vi.mock('@/path/to/composable', () => {
const doSomething = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useMyComposable: () => ({
doSomething,
isLoading,
error
})
}
})
describe('MyStore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call the composable method', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue({ data: 'test' })
await store.initialize()
expect(service.doSomething).toHaveBeenCalledWith(expectedArgs)
})
it('should handle errors from the composable', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue(null)
service.error.value = 'Something went wrong'
await store.initialize()
expect(store.error).toBe('Something went wrong')
})
})
```
### Anti-patterns
```typescript
// ❌ Don't configure mock return values in beforeEach with shared variable
let mockService: { doSomething: Mock }
beforeEach(() => {
mockService = { doSomething: vi.fn() }
vi.mocked(useMyComposable).mockReturnValue(mockService)
})
// ❌ Don't auto-mock then override — reactive refs won't work correctly
vi.mock('@/path/to/composable')
vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) })
```
```
```

1
global.d.ts vendored
View File

@@ -30,6 +30,7 @@ interface Window {
badge?: string
}
}
dataLayer?: Array<Record<string, unknown>>
}
interface Navigator {

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.39.0",
"version": "1.38.12",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -2,8 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import type { Component } from 'vue'
import { computed, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
@@ -15,7 +14,6 @@ import type {
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
@@ -38,17 +36,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
attachTo?: HTMLElement
}
function createWrapper({
pinia = createTestingPinia({ createSpy: vi.fn }),
stubs = {},
attachTo
}: WrapperOptions = {}) {
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -67,21 +55,18 @@ function createWrapper({
})
return mount(TopMenuSection, {
attachTo,
global: {
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
},
...stubs
}
},
directives: {
tooltip: () => {}
@@ -106,7 +91,6 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
})
describe('authentication state', () => {
@@ -167,7 +151,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
await nextTick()
@@ -185,7 +169,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -201,7 +185,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -215,7 +199,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
@@ -226,84 +210,6 @@ describe('TopMenuSection', () => {
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders inline progress summary when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(true)
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueInlineProgressSummary: false
}
})
try {
await nextTick()
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
wrapper.unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })

View File

@@ -1,130 +1,101 @@
<template>
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex flex-col gap-1 pt-1"
class="ml-1 flex gap-x-0.5 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div class="flex gap-x-0.5">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div>
<Teleport
v-if="inlineProgressSummaryTarget"
:to="inlineProgressSummaryTarget"
>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
<div
class="actionbar-container pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
@@ -133,7 +104,6 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
@@ -177,15 +147,6 @@ const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const actionbarContainerRef = ref<HTMLElement>()
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const actionbarPosition = computed(() => settingStore.get('Comfy.UseNewMenu'))
const isActionbarEnabled = computed(
() => actionbarPosition.value !== 'Disabled'
)
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -203,19 +164,6 @@ const isQueuePanelV2Enabled = computed(() =>
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const progressTarget = ref<HTMLElement | null>(null)
function updateProgressTarget(target: HTMLElement | null) {
progressTarget.value = target
}
const inlineProgressSummaryTarget = computed(() => {
if (!shouldShowInlineProgressSummary.value || !isActionbarFloating.value) {
return null
}
return progressTarget.value
})
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)

View File

@@ -10,7 +10,6 @@
</div>
<Panel
ref="panelRef"
class="pointer-events-auto"
:style="style"
:class="panelClass"
@@ -19,7 +18,7 @@
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
>
<div class="relative flex items-center select-none gap-2">
<div ref="panelRef" class="flex items-center select-none gap-2">
<span
ref="dragHandleRef"
:class="
@@ -44,14 +43,6 @@
</Button>
</div>
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
</Teleport>
</div>
</template>
@@ -60,17 +51,14 @@ import {
useDraggable,
useEventListener,
useLocalStorage,
unrefElement,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -81,15 +69,6 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
}>()
const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
@@ -97,22 +76,15 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
const element = unrefElement(panelRef)
return element instanceof HTMLElement ? element : null
})
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const { x, y, style, isDragging } = useDraggable(panelElement, {
const { x, y, style, isDragging } = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
@@ -129,12 +101,11 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
const panel = panelElement.value
if (panel) {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
@@ -210,12 +181,11 @@ watch(
)
const adjustMenuPosition = () => {
const panel = panelElement.value
if (panel) {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
@@ -282,19 +252,6 @@ const onMouseLeaveDropZone = () => {
}
}
const inlineProgressTarget = computed(() => {
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
watch(
panelElement,
(target) => {
emit('update:progressTarget', target)
},
{ immediate: true }
)
// Handle drag state changes
watch(isDragging, (dragging) => {
if (dragging) {

View File

@@ -1,206 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import WorkspaceAuthGate from './WorkspaceAuthGate.vue'
const mockIsInitialized = ref(false)
const mockCurrentUser = ref<object | null>(null)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
isInitialized: mockIsInitialized,
currentUser: mockCurrentUser
})
}))
const mockRefreshRemoteConfig = vi.fn()
vi.mock('@/platform/remoteConfig/refreshRemoteConfig', () => ({
refreshRemoteConfig: (options: unknown) => mockRefreshRemoteConfig(options)
}))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
const mockWorkspaceStoreInitialize = vi.fn()
const mockWorkspaceStoreInitState = vi.hoisted(() => ({
value: 'uninitialized' as string
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get initState() {
return mockWorkspaceStoreInitState.value
},
initialize: mockWorkspaceStoreInitialize
})
}))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
vi.mock('primevue/progressspinner', () => ({
default: { template: '<div class="progress-spinner" />' }
}))
describe('WorkspaceAuthGate', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsInitialized.value = false
mockCurrentUser.value = null
mockTeamWorkspacesEnabled.value = false
mockWorkspaceStoreInitState.value = 'uninitialized'
mockRefreshRemoteConfig.mockResolvedValue(undefined)
mockWorkspaceStoreInitialize.mockResolvedValue(undefined)
})
const mountComponent = () =>
mount(WorkspaceAuthGate, {
slots: {
default: '<div data-testid="slot-content">App Content</div>'
}
})
describe('non-cloud builds', () => {
it('renders slot immediately when isCloud is false', async () => {
mockIsCloud.value = false
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(wrapper.find('.progress-spinner').exists()).toBe(false)
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
})
})
describe('cloud builds - unauthenticated user', () => {
it('shows spinner while waiting for Firebase auth', () => {
mockIsInitialized.value = false
const wrapper = mountComponent()
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
})
it('renders slot when Firebase initializes with no user', async () => {
mockIsInitialized.value = false
const wrapper = mountComponent()
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
mockIsInitialized.value = true
mockCurrentUser.value = null
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
})
})
describe('cloud builds - authenticated user', () => {
beforeEach(() => {
mockIsInitialized.value = true
mockCurrentUser.value = { uid: 'user-123' }
})
it('refreshes remote config with auth after Firebase init', async () => {
mountComponent()
await flushPromises()
expect(mockRefreshRemoteConfig).toHaveBeenCalledWith({ useAuth: true })
})
it('renders slot when teamWorkspacesEnabled is false', async () => {
mockTeamWorkspacesEnabled.value = false
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(mockWorkspaceStoreInitialize).not.toHaveBeenCalled()
})
it('initializes workspace store when teamWorkspacesEnabled is true', async () => {
mockTeamWorkspacesEnabled.value = true
const wrapper = mountComponent()
await flushPromises()
expect(mockWorkspaceStoreInitialize).toHaveBeenCalled()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
it('skips workspace init when store is already initialized', async () => {
mockTeamWorkspacesEnabled.value = true
mockWorkspaceStoreInitState.value = 'ready'
const wrapper = mountComponent()
await flushPromises()
expect(mockWorkspaceStoreInitialize).not.toHaveBeenCalled()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
})
describe('error handling - graceful degradation', () => {
beforeEach(() => {
mockIsInitialized.value = true
mockCurrentUser.value = { uid: 'user-123' }
})
it('renders slot when remote config refresh fails', async () => {
mockRefreshRemoteConfig.mockRejectedValue(new Error('Network error'))
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
it('renders slot when remote config refresh times out', async () => {
vi.useFakeTimers()
// Never-resolving promise simulates a hanging request
mockRefreshRemoteConfig.mockReturnValue(new Promise(() => {}))
const wrapper = mountComponent()
await flushPromises()
// Still showing spinner before timeout
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
// Advance past the 10 second timeout
await vi.advanceTimersByTimeAsync(10_001)
await flushPromises()
// Should render slot after timeout (graceful degradation)
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
vi.useRealTimers()
})
it('renders slot when workspace store initialization fails', async () => {
mockTeamWorkspacesEnabled.value = true
mockWorkspaceStoreInitialize.mockRejectedValue(
new Error('Workspace init failed')
)
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
})
})

View File

@@ -1,129 +0,0 @@
<template>
<slot v-if="isReady" />
<div
v-else
class="fixed inset-0 z-[1100] flex items-center justify-center bg-[var(--p-mask-background)]"
>
<ProgressSpinner />
</div>
</template>
<script setup lang="ts">
/**
* WorkspaceAuthGate - Conditional auth checkpoint for workspace mode.
*
* This gate ensures proper initialization order for workspace-scoped auth:
* 1. Wait for Firebase auth to resolve
* 2. Check if teamWorkspacesEnabled feature flag is on
* 3. If YES: Initialize workspace token and store before rendering
* 4. If NO: Render immediately using existing Firebase auth
*
* This prevents race conditions where API calls use Firebase tokens
* instead of workspace tokens when the workspace feature is enabled.
*/
import { promiseTimeout, until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { onMounted, ref } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const FIREBASE_INIT_TIMEOUT_MS = 16_000
const CONFIG_REFRESH_TIMEOUT_MS = 10_000
const isReady = ref(!isCloud)
async function initialize(): Promise<void> {
if (!isCloud) return
const authStore = useFirebaseAuthStore()
const { isInitialized, currentUser } = storeToRefs(authStore)
try {
// Step 1: Wait for Firebase auth to resolve
// This is shared with router guard - both wait for the same thing,
// but this gate blocks rendering while router guard blocks navigation
if (!isInitialized.value) {
await until(isInitialized).toBe(true, {
timeout: FIREBASE_INIT_TIMEOUT_MS
})
}
// Step 2: If not authenticated, nothing more to do
// Unauthenticated users don't have workspace context
if (!currentUser.value) {
isReady.value = true
return
}
// Step 3: Refresh feature flags with auth context
// This ensures teamWorkspacesEnabled reflects the authenticated user's state
// Timeout prevents hanging if server is slow/unresponsive
try {
await Promise.race([
refreshRemoteConfig({ useAuth: true }),
promiseTimeout(CONFIG_REFRESH_TIMEOUT_MS).then(() => {
throw new Error('Config refresh timeout')
})
])
} catch (error) {
console.warn(
'[WorkspaceAuthGate] Failed to refresh remote config:',
error
)
// Continue - feature flags will use defaults (teamWorkspacesEnabled=false)
// App will render with Firebase auth fallback
}
// Step 4: THE CHECKPOINT - Are we in workspace mode?
const { flags } = useFeatureFlags()
if (!flags.teamWorkspacesEnabled) {
// Not in workspace mode - use existing Firebase auth flow
// No additional initialization needed
isReady.value = true
return
}
// Step 5: WORKSPACE MODE - Full initialization
await initializeWorkspaceMode()
} catch (error) {
console.error('[WorkspaceAuthGate] Initialization failed:', error)
} finally {
// Always render (graceful degradation)
// If workspace init failed, API calls fall back to Firebase token
isReady.value = true
}
}
async function initializeWorkspaceMode(): Promise<void> {
// Initialize the full workspace store which handles:
// - Restoring workspace token from session (fast path for refresh)
// - Fetching workspace list
// - Switching to last used workspace if needed
// - Setting active workspace
try {
const workspaceStore = useTeamWorkspaceStore()
if (workspaceStore.initState === 'uninitialized') {
await workspaceStore.initialize()
}
} catch (error) {
// Log but don't block - workspace UI features may not work but app will render
// API calls will fall back to Firebase token
console.warn(
'[WorkspaceAuthGate] Failed to initialize workspace store:',
error
)
}
}
// Initialize on mount. This gate should be placed on the authenticated layout
// (LayoutDefault) so it mounts fresh after login and unmounts on logout.
// The router guard ensures only authenticated users reach this layout.
onMounted(() => {
void initialize()
})
</script>

View File

@@ -1,49 +0,0 @@
<template>
<Transition name="fade">
<div
v-if="loading"
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
>
<div class="flex flex-col items-center">
<div class="grid place-items-center">
<div
:class="
cn(
'col-start-1 row-start-1 animate-spin rounded-full border-muted-foreground border-t-base-foreground',
spinnerSizeClass
)
"
/>
<div class="col-start-1 row-start-1">
<slot />
</div>
</div>
<div v-if="loadingMessage" class="mt-4 text-lg text-base-foreground">
{{ loadingMessage }}
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { size = 'md' } = defineProps<{
loading: boolean
loadingMessage?: string
size?: 'sm' | 'md'
}>()
const spinnerSizeClass = computed(() => {
switch (size) {
case 'sm':
return 'h-6 w-6 border-2'
case 'md':
default:
return 'h-12 w-12 border-4'
}
})
</script>

View File

@@ -14,12 +14,7 @@
</template>
<template #header>
<SearchBox
v-model="searchQuery"
size="lg"
class="max-w-[384px]"
autofocus
/>
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
</template>
<template #header-right-area>

View File

@@ -149,7 +149,7 @@ 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 { useNewUserService } from '@/services/useNewUserService'
import { newUserService } from '@/services/newUserService'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
@@ -457,9 +457,11 @@ onMounted(async () => {
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
// Wait for both i18n and newUserService in parallel
// (newUserService only needs settings, not i18n)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
useNewUserService().initializeIfNewUser()
newUserService().initializeIfNewUser(settingStore)
])
if (i18nError.value) {
console.warn(
@@ -500,9 +502,19 @@ onMounted(async () => {
await workflowPersistence.loadTemplateFromUrlIfPresent()
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
await inviteUrlLoader.loadInviteFromUrl()
// Uses watch because feature flags load asynchronously - flag may be false initially
// then become true once remoteConfig or websocket features are loaded
if (inviteUrlLoader) {
const stopWatching = watch(
() => flags.teamWorkspacesEnabled,
async (enabled) => {
if (enabled) {
stopWatching()
await inviteUrlLoader.loadInviteFromUrl()
}
},
{ immediate: true }
)
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)

View File

@@ -13,8 +13,6 @@ import {
createMockCanvas,
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
import * as litegraphUtil from '@/utils/litegraphUtil'
import * as nodeFilterUtil from '@/utils/nodeFilterUtil'
function createMockExtensionService(): ReturnType<typeof useExtensionService> {
return {
@@ -291,8 +289,9 @@ describe('SelectionToolbox', () => {
)
})
it('should show mask editor only for single image nodes', () => {
const isImageNodeSpy = vi.spyOn(litegraphUtil, 'isImageNode')
it('should show mask editor only for single image nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isImageNodeSpy = vi.spyOn(mockUtils, 'isImageNode')
// Single image node
isImageNodeSpy.mockReturnValue(true)
@@ -308,8 +307,9 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
})
it('should show Color picker button only for single Load3D nodes', () => {
const isLoad3dNodeSpy = vi.spyOn(litegraphUtil, 'isLoad3dNode')
it('should show Color picker button only for single Load3D nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isLoad3dNodeSpy = vi.spyOn(mockUtils, 'isLoad3dNode')
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
@@ -325,9 +325,13 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
})
it('should show ExecuteButton only when output nodes are selected', () => {
const isOutputNodeSpy = vi.spyOn(nodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(nodeFilterUtil, 'filterOutputNodes')
it('should show ExecuteButton only when output nodes are selected', async () => {
const mockNodeFilterUtil = await import('@/utils/nodeFilterUtil')
const isOutputNodeSpy = vi.spyOn(mockNodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(
mockNodeFilterUtil,
'filterOutputNodes'
)
// With output node selected
isOutputNodeSpy.mockReturnValue(true)

View File

@@ -7,7 +7,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -48,7 +47,7 @@ describe('ExecuteButton', () => {
}
})
beforeEach(() => {
beforeEach(async () => {
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
@@ -72,7 +71,10 @@ describe('ExecuteButton', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
// Update the useSelectionState mock
vi.mocked(useSelectionState).mockReturnValue({
const { useSelectionState } = vi.mocked(
await import('@/composables/graph/useSelectionState')
)
useSelectionState.mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
}

View File

@@ -31,7 +31,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
const props = defineProps<{

View File

@@ -0,0 +1,42 @@
<template>
<Transition name="fade">
<div
v-if="loading"
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
>
<div class="flex flex-col items-center">
<div class="spinner" />
<div class="mt-4 text-lg text-base-foreground">
{{ loadingMessage }}
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
defineProps<{
loading: boolean
loadingMessage: string
}>()
</script>
<style scoped>
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,75 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
const mockProgress = vi.hoisted(() => ({
totalPercent: null as unknown as Ref<number>,
currentNodePercent: null as unknown as Ref<number>
}))
vi.mock('@/composables/queue/useQueueProgress', () => ({
useQueueProgress: () => ({
totalPercent: mockProgress.totalPercent,
currentNodePercent: mockProgress.currentNodePercent
})
}))
const createWrapper = (props: { hidden?: boolean } = {}) =>
mount(QueueInlineProgress, { props })
describe('QueueInlineProgress', () => {
beforeEach(() => {
mockProgress.totalPercent = ref(0)
mockProgress.currentNodePercent = ref(0)
})
it('renders when total progress is non-zero', () => {
mockProgress.totalPercent.value = 12
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('renders when current node progress is non-zero', () => {
mockProgress.currentNodePercent.value = 33
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('does not render when hidden', () => {
mockProgress.totalPercent.value = 45
const wrapper = createWrapper({ hidden: true })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
it('shows when progress becomes non-zero', async () => {
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
mockProgress.totalPercent.value = 10
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('hides when progress returns to zero', async () => {
mockProgress.totalPercent.value = 10
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
mockProgress.totalPercent.value = 0
mockProgress.currentNodePercent.value = 0
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
})

View File

@@ -1,36 +0,0 @@
<template>
<div
v-if="shouldShow"
aria-hidden="true"
:class="
cn('pointer-events-none absolute inset-0 overflow-hidden', radiusClass)
"
>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${totalPercent}%` }"
/>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${currentNodePercent}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { cn } from '@/utils/tailwindUtil'
const { hidden = false, radiusClass = 'rounded-[7px]' } = defineProps<{
hidden?: boolean
radiusClass?: string
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const shouldShow = computed(
() => !hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
)
</script>

View File

@@ -1,70 +0,0 @@
<template>
<div v-if="shouldShow" class="flex justify-end">
<div
class="flex items-center whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div class="flex items-center text-base-foreground">
<span class="font-normal">
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
</span>
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
{{ totalPercentFormatted }}
</span>
</div>
<div class="flex items-center text-muted-foreground">
<span
class="w-[16ch] shrink-0 truncate text-right"
:title="currentNodeName"
>
{{ currentNodeName }}:
</span>
<span class="w-[5ch] shrink-0 text-right tabular-nums">
{{ currentNodePercentFormatted }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const props = defineProps<{
hidden?: boolean
}>()
const { t } = useI18n()
const executionStore = useExecutionStore()
const {
totalPercent,
totalPercentFormatted,
currentNodePercent,
currentNodePercentFormatted
} = useQueueProgress()
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
})
const shouldShow = computed(
() =>
!props.hidden &&
(!executionStore.isIdle ||
totalPercent.value > 0 ||
currentNodePercent.value > 0)
)
</script>

View File

@@ -8,14 +8,12 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabInfo from './info/TabInfo.vue'
@@ -148,12 +146,9 @@ function resolveTitle() {
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
}
if (nodes.length === 1) {
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(nodes[0], {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
return (
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
)
}
}
return t('rightSidePanel.title', { count: items.length })

View File

@@ -14,8 +14,6 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { GetNodeParentGroupKey } from '../shared'
import WidgetItem from './WidgetItem.vue'
@@ -54,7 +52,7 @@ const rootElement = ref<HTMLElement>()
const widgets = shallowRef(widgetsProp)
watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
provide('hideLayoutField', true)
const canvasStore = useCanvasStore()
const { t } = useI18n()

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -17,7 +15,6 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
@@ -41,7 +38,6 @@ const {
isShownOnParents?: boolean
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
@@ -63,13 +59,7 @@ const sourceNodeName = computed((): string | null => {
const { graph, nodeId } = widget._overlay
sourceNode = getNodeByExecutionId(graph, nodeId)
}
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
return sourceNode ? sourceNode.title || sourceNode.type : null
})
const hasParents = computed(() => parents?.length > 0)

View File

@@ -1,13 +1,27 @@
<template>
<div class="_content">
<SelectButton
v-model="selectedFilter"
class="filter-type-select"
:options="filters"
:allow-empty="false"
option-label="name"
@change="updateSelectedFilterValue"
/>
<div class="flex flex-col space-y-2">
<div
class="flex flex-wrap gap-2"
role="tablist"
:aria-label="$t('sideToolbar.nodeLibraryTab.filterCategory')"
>
<Button
v-for="filterOption in filters"
:key="filterOption.id"
type="button"
size="sm"
:variant="
selectedFilter?.id === filterOption.id
? 'secondary'
: 'muted-textonly'
"
class="flex-1 justify-center px-3 py-2 text-sm"
:aria-pressed="selectedFilter?.id === filterOption.id"
@click="selectFilterOption(filterOption)"
>
{{ filterOption.name }}
</Button>
</div>
<Select
v-model="selectedFilterValue"
class="filter-value-select"
@@ -16,14 +30,13 @@
auto-filter-focus
/>
</div>
<div class="_footer">
<div class="flex flex-col pt-4 items-end">
<Button type="button" @click="submit">{{ $t('g.add') }}</Button>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import SelectButton from 'primevue/selectbutton'
import { computed, onMounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
@@ -57,6 +70,16 @@ const updateSelectedFilterValue = () => {
selectedFilterValue.value = filterValues.value[0]
}
const selectFilterOption = (
filterOption: FuseFilter<ComfyNodeDefImpl, string>
) => {
if (selectedFilter.value?.id === filterOption.id) {
return
}
selectedFilter.value = filterOption
updateSelectedFilterValue()
}
const submit = () => {
if (!selectedFilter.value) {
return
@@ -67,15 +90,3 @@ const submit = () => {
})
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
._content {
@apply flex flex-col space-y-2;
}
._footer {
@apply flex flex-col pt-4 items-end;
}
</style>

View File

@@ -63,47 +63,39 @@
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<div class="relative">
<LoadingOverlay
:loading="assetsStore.isAssetDeleting(item.asset.id)"
size="sm"
>
<i class="pi pi-trash text-xs" />
</LoadingOverlay>
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
type: getMediaTypeFromFilename(item.asset.name)
})
"
:class="getAssetCardClass(isSelected(item.asset.id))"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
variant="secondary"
size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop="emit('context-menu', $event, item.asset)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
type: getMediaTypeFromFilename(item.asset.name)
})
"
:class="getAssetCardClass(isSelected(item.asset.id))"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
variant="secondary"
size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop="emit('context-menu', $event, item.asset)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</template>
</VirtualGrid>
</div>
@@ -113,7 +105,6 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
@@ -123,7 +114,6 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import { isActiveJobState } from '@/utils/queueUtil'
import {
formatDuration,
@@ -144,8 +134,6 @@ const {
assetType?: 'input' | 'output'
}>()
const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void

View File

@@ -331,7 +331,7 @@ const {
const {
downloadMultipleAssets,
deleteAssets,
deleteMultipleAssets,
addMultipleToWorkflow,
openMultipleWorkflows,
exportMultipleWorkflows
@@ -495,9 +495,8 @@ const handleBulkDownload = (assets: AssetItem[]) => {
}
const handleBulkDelete = async (assets: AssetItem[]) => {
if (await deleteAssets(assets)) {
clearSelection()
}
await deleteMultipleAssets(assets)
clearSelection()
}
const handleClearQueue = async () => {
@@ -525,17 +524,6 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
clearSelection()
}
const handleDownloadSelected = () => {
downloadMultipleAssets(selectedAssets.value)
clearSelection()
}
const handleDeleteSelected = async () => {
if (await deleteAssets(selectedAssets.value)) {
clearSelection()
}
}
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
@@ -684,6 +672,16 @@ const copyJobId = async () => {
}
}
const handleDownloadSelected = () => {
downloadMultipleAssets(selectedAssets.value)
clearSelection()
}
const handleDeleteSelected = async () => {
await deleteMultipleAssets(selectedAssets.value)
clearSelection()
}
const handleApproachEnd = useDebounceFn(async () => {
if (
activeTab.value === 'output' &&

View File

@@ -1,11 +1,7 @@
<template>
<div
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
props.class
)
"
class="comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col"
:class="props.class"
>
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
@@ -39,8 +35,6 @@
import ScrollPanel from 'primevue/scrollpanel'
import Toolbar from 'primevue/toolbar'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
title: string
class?: string

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
import { whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
@@ -10,7 +10,6 @@ defineProps<{
}>()
const feedbackRef = useTemplateRef('feedbackRef')
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
@@ -19,20 +18,9 @@ whenever(feedbackRef, () => {
})
</script>
<template>
<Button
v-if="isMobile"
as="a"
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="rounded-full size-12"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
<Popover v-else>
<Popover>
<template #button>
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
<Button variant="inverted" class="rounded-full size-12">
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
</template>

View File

@@ -3,7 +3,7 @@
v-tooltip.right="{
value: tooltipText,
disabled: !isOverflowing,
pt: { text: { class: 'w-max whitespace-nowrap' } }
pt: { text: { class: 'whitespace-nowrap' } }
}"
class="flex cursor-pointer select-none items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="

View File

@@ -17,7 +17,7 @@ import {
isToday,
isYesterday
} from '@/utils/dateTimeUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
@@ -185,11 +185,13 @@ export function useJobList() {
executionStore.isPromptInitializing(promptId)
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
})
const selectedJobTab = ref<JobTab>('All')

View File

@@ -40,7 +40,7 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
}))
const mediaAssetActionsMock = {
deleteAssets: vi.fn()
confirmDelete: vi.fn()
}
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => mediaAssetActionsMock
@@ -198,7 +198,7 @@ describe('useJobMenu', () => {
}))
queueStoreMock.update.mockResolvedValue(undefined)
queueStoreMock.delete.mockResolvedValue(undefined)
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false)
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
task,
output
@@ -666,7 +666,7 @@ describe('useJobMenu', () => {
})
it('deletes preview asset when confirmed', async () => {
mediaAssetActionsMock.deleteAssets.mockResolvedValue(true)
mediaAssetActionsMock.confirmDelete.mockResolvedValue(true)
const { jobMenuEntries } = mountJobMenu()
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
const taskRef = { previewOutput: preview }
@@ -681,7 +681,7 @@ describe('useJobMenu', () => {
})
it('does not refresh queue when delete cancelled', async () => {
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({

View File

@@ -210,8 +210,8 @@ export function useJobMenu(
if (!task || !preview) return
const asset = mapTaskOutputToAssetItem(task, preview)
const confirmed = await mediaAssetActions.deleteAssets(asset)
if (confirmed) {
const success = await mediaAssetActions.confirmDelete(asset)
if (success) {
await queueStore.update()
}
}

View File

@@ -1,10 +1,7 @@
import { computed, reactive, readonly } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import {
isAuthenticatedConfigLoaded,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
/**
@@ -98,20 +95,9 @@ export function useFeatureFlags() {
)
)
},
/**
* Whether team workspaces feature is enabled.
* IMPORTANT: Returns false until authenticated remote config is loaded.
* This ensures we never use workspace tokens when the feature is disabled,
* and prevents race conditions during initialization.
*/
get teamWorkspacesEnabled() {
if (!isCloud) return false
// Only return true if authenticated config has been loaded.
// This prevents race conditions where code checks this flag before
// WorkspaceAuthGate has refreshed the config with auth.
if (!isAuthenticatedConfigLoaded.value) return false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)

View File

@@ -4,7 +4,6 @@ import { nextTick, ref } from 'vue'
import type { IFuseOptions } from 'fuse.js'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
const defaultSettingStore = {
get: vi.fn((key: string) => {
@@ -51,6 +50,9 @@ vi.mock('@/scripts/api', () => ({
}
}))
const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering')
describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())

View File

@@ -16,9 +16,6 @@ useExtensionService().registerExtension({
const { isLoggedIn } = useCurrentUser()
const { isActiveSubscription } = useSubscription()
// Refresh config when auth or subscription status changes
// Primary auth refresh is handled by WorkspaceAuthGate on mount
// This watcher handles subscription changes and acts as a backup for auth
watchDebounced(
[isLoggedIn, isActiveSubscription],
() => {

View File

@@ -55,7 +55,6 @@ class Load3d {
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
private resizeObserver: ResizeObserver | null = null
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
this.clock = new THREE.Clock()
@@ -146,7 +145,6 @@ class Load3d {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.handleResize()
this.startAnimation()
@@ -156,16 +154,6 @@ class Load3d {
}, 100)
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
@@ -524,6 +512,7 @@ class Load3d {
this.viewHelperManager.recreateViewHelper()
this.handleResize()
this.forceRender()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
@@ -585,6 +574,7 @@ class Load3d {
}
this.handleResize()
this.forceRender()
this.loadingPromise = null
}
@@ -618,6 +608,7 @@ class Load3d {
this.targetHeight = height
this.targetAspectRatio = width / height
this.handleResize()
this.forceRender()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
@@ -630,6 +621,7 @@ class Load3d {
refreshViewport(): void {
this.handleResize()
this.forceRender()
}
handleResize(): void {
@@ -817,11 +809,6 @@ class Load3d {
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null

View File

@@ -10,17 +10,8 @@ import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
IWidgetAssetOptions,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import {
assetFilenameSchema,
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { isCloud } from '@/platform/distribution/types'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import {
@@ -28,10 +19,10 @@ import {
addValueControlWidgets,
isValidWidgetType
} from '@/scripts/widgets'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
@@ -237,20 +228,6 @@ export class PrimitiveNode extends LGraphNode {
// Store current size as addWidget resizes the node
const [oldWidth, oldHeight] = this.size
let widget: IBaseWidget
// Cloud: Use asset widget for model-eligible inputs
if (isCloud && type === 'COMBO') {
const isEligible = assetService.isAssetBrowserEligible(
node.comfyClass,
widgetName
)
if (isEligible) {
widget = this.#createAssetWidget(node, widgetName, inputData)
this.#finalizeWidget(widget, oldWidth, oldHeight, recreating)
return
}
}
if (isValidWidgetType(type)) {
widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget
} else {
@@ -300,84 +277,20 @@ export class PrimitiveNode extends LGraphNode {
}
}
this.#finalizeWidget(widget, oldWidth, oldHeight, recreating)
}
#createAssetWidget(
targetNode: LGraphNode,
widgetName: string,
inputData: InputSpec
): IBaseWidget {
const defaultValue = inputData[1]?.default as string | undefined
const assetBrowserDialog = useAssetBrowserDialog()
const openModal = async (widget: IBaseWidget) => {
await assetBrowserDialog.show({
nodeType: targetNode.comfyClass ?? '',
inputName: widgetName,
currentValue: widget.value as string,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error('Invalid asset item:', validatedAsset.error.errors)
return
}
const filename = getAssetFilename(validatedAsset.data)
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors
)
return
}
const oldValue = widget.value
widget.value = validatedFilename.data
widget.callback?.(
widget.value,
app.canvas,
this,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
this.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
const options: IWidgetAssetOptions = { openModal }
return this.addWidget(
'asset',
'value',
defaultValue ?? '',
() => {},
options
)
}
#finalizeWidget(
widget: IBaseWidget,
oldWidth: number,
oldHeight: number,
recreating: boolean
) {
// When our value changes, update other widgets to reflect our changes
// e.g. so LoadImage shows correct image
widget.callback = useChainCallback(widget.callback, () => {
this.applyToGraph()
})
// Use the biggest dimensions in case the widgets caused the node to grow
this.setSize([
Math.max(this.size[0], oldWidth),
Math.max(this.size[1], oldHeight)
])
if (!recreating) {
// Grow our node more if required
const sz = this.computeSize()
if (this.size[0] < sz[0]) {
this.size[0] = sz[0]

View File

@@ -46,9 +46,12 @@ describe('LGraph', () => {
expect(graph.extra).toBe('TestGraph')
})
test('is exactly the same type', ({ expect }) => {
// LGraph from barrel export and LiteGraph.LGraph should be the same
expect(LiteGraph.LGraph).toBe(LGraph)
test('is exactly the same type', async ({ expect }) => {
const directImport = await import('@/lib/litegraph/src/LGraph')
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
expect(LiteGraph.LGraph).toBe(directImport.LGraph)
expect(LiteGraph.LGraph).toBe(entryPointImport.LGraph)
})
test('populates optional values', ({ expect, minimalSerialisableGraph }) => {

View File

@@ -137,13 +137,6 @@ describe('LGraphNode', () => {
expect(node.id).toEqual(1)
expect(node.outputs.length).toEqual(1)
})
test('should not allow configuring id to -1', () => {
const graph = new LGraph()
const node = new LGraphNode('TestNode')
graph.add(node)
node.configure(getMockISerialisedNode({ id: -1 }))
expect(node.id).not.toBe(-1)
})
describe('Disconnect I/O Slots', () => {
test('should disconnect input correctly', () => {

View File

@@ -785,7 +785,6 @@ export class LGraphNode
if (this.graph) {
this.graph._version++
}
if (info.id === -1) info.id = this.id
for (const j in info) {
if (j == 'properties') {
// i don't want to clone properties, I want to reuse the old container

View File

@@ -1,15 +1,12 @@
import { clamp } from 'es-toolkit/compat'
import { describe, expect } from 'vitest'
import { beforeEach, describe, expect, vi } from 'vitest'
import {
LiteGraphGlobal,
LGraphCanvas,
LiteGraph,
LGraph
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { LGraph as DirectLGraph } from '@/lib/litegraph/src/LGraph'
import { test } from './__fixtures__/testExtensions'
describe('Litegraph module', () => {
@@ -30,9 +27,22 @@ describe('Litegraph module', () => {
})
describe('Import order dependency', () => {
test('Imports reference the same types', ({ expect }) => {
// Both imports should reference the same LGraph class
expect(LiteGraph.LGraph).toBe(DirectLGraph)
expect(LiteGraph.LGraph).toBe(LGraph)
beforeEach(() => {
vi.resetModules()
})
test('Imports without error when entry point is imported first', async ({
expect
}) => {
async function importNormally() {
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
const directImport = await import('@/lib/litegraph/src/LGraph')
// Sanity check that imports were cleared.
expect(Object.is(LiteGraph, entryPointImport.LiteGraph)).toBe(false)
expect(Object.is(LiteGraph.LGraph, directImport.LGraph)).toBe(false)
}
await expect(importNormally()).resolves.toBeUndefined()
})
})

View File

@@ -28,8 +28,8 @@ import type {
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -331,10 +331,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
inputWidget: IWidgetLocator | undefined
) {
// Use the first matching widget
const promotedWidget =
widget instanceof BaseWidget
? widget.createCopyForNode(this)
: { ...widget, node: this }
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
this
)
if (widget instanceof AssetWidget)
promotedWidget.options.nodeType ??= widget.node.type

View File

@@ -1,261 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode as LGraphNodeType } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
import type { ColorWidget as ColorWidgetType } from '@/lib/litegraph/src/widgets/ColorWidget'
type LGraphCanvasType = InstanceType<typeof LGraphCanvas>
function createMockWidgetConfig(
overrides: Partial<IColorWidget> = {}
): IColorWidget {
return {
type: 'color',
name: 'test_color',
value: '#ff0000',
options: {},
y: 0,
...overrides
}
}
function createMockCanvas(): LGraphCanvasType {
return {
setDirty: vi.fn()
} as Partial<LGraphCanvasType> as LGraphCanvasType
}
function createMockEvent(clientX = 100, clientY = 200): CanvasPointerEvent {
return { clientX, clientY } as CanvasPointerEvent
}
describe('ColorWidget', () => {
let node: LGraphNodeType
let widget: ColorWidgetType
let mockCanvas: LGraphCanvasType
let mockEvent: CanvasPointerEvent
let ColorWidget: typeof ColorWidgetType
let LGraphNode: typeof LGraphNodeType
beforeEach(async () => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset modules to get fresh globalColorInput state
vi.resetModules()
const litegraph = await import('@/lib/litegraph/src/litegraph')
LGraphNode = litegraph.LGraphNode
const colorWidgetModule =
await import('@/lib/litegraph/src/widgets/ColorWidget')
ColorWidget = colorWidgetModule.ColorWidget
node = new LGraphNode('TestNode')
mockCanvas = createMockCanvas()
mockEvent = createMockEvent()
})
afterEach(() => {
vi.useRealTimers()
document
.querySelectorAll('input[type="color"]')
.forEach((el) => el.remove())
})
describe('onClick', () => {
it('should create a color input and append it to document body', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input).toBeTruthy()
expect(input.parentElement).toBe(document.body)
})
it('should set input value from widget value', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#00ff00' }),
node
)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#00ff00')
})
it('should default to #000000 when widget value is empty', () => {
widget = new ColorWidget(createMockWidgetConfig({ value: '' }), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#000000')
})
it('should position input at click coordinates', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const event = createMockEvent(150, 250)
widget.onClick({ e: event, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.style.left).toBe('150px')
expect(input.style.top).toBe('250px')
})
it('should click the input on next animation frame', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
expect(clickSpy).not.toHaveBeenCalled()
vi.runAllTimers()
expect(clickSpy).toHaveBeenCalled()
})
it('should reuse the same input element on subsequent clicks', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const firstInput = document.querySelector('input[type="color"]')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const secondInput = document.querySelector('input[type="color"]')
expect(firstInput).toBe(secondInput)
expect(document.querySelectorAll('input[type="color"]').length).toBe(1)
})
it('should update input value when clicking with different widget values', () => {
const widget1 = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const widget2 = new ColorWidget(
createMockWidgetConfig({ value: '#0000ff' }),
node
)
widget1.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#ff0000')
widget2.onClick({ e: mockEvent, node, canvas: mockCanvas })
expect(input.value).toBe('#0000ff')
})
})
describe('onChange', () => {
it('should call setValue when color input changes', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', {
e: mockEvent,
node,
canvas: mockCanvas
})
})
it('should call canvas.setDirty after value change', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
})
it('should remove change listener after firing once', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
// Should only be called once despite two change events
expect(setValueSpy).toHaveBeenCalledTimes(1)
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', expect.any(Object))
})
it('should register new change listener on subsequent onClick', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
// First click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
// Second click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledTimes(2)
expect(setValueSpy).toHaveBeenNthCalledWith(
1,
'#00ff00',
expect.any(Object)
)
expect(setValueSpy).toHaveBeenNthCalledWith(
2,
'#0000ff',
expect.any(Object)
)
})
})
describe('type', () => {
it('should have type "color"', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
expect(widget.type).toBe('color')
})
})
})

View File

@@ -1,26 +1,12 @@
import { t } from '@/i18n'
import type { IColorWidget } from '../types/widgets'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
import { BaseWidget } from './BaseWidget'
// Have one color input to prevent leaking instances
// Browsers don't seem to fire any events when the color picker is cancelled
let colorInput: HTMLInputElement | null = null
function getColorInput(): HTMLInputElement {
if (!colorInput) {
colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.style.position = 'absolute'
colorInput.style.opacity = '0'
colorInput.style.pointerEvents = 'none'
colorInput.style.zIndex = '-999'
document.body.appendChild(colorInput)
}
return colorInput
}
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for displaying a color picker using native HTML color input
* Widget for displaying a color picker
* This is a widget that only has a Vue widgets implementation
*/
export class ColorWidget
extends BaseWidget<IColorWidget>
@@ -29,59 +15,35 @@ export class ColorWidget
override type = 'color' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
const { width } = options
const { height, y } = this
const { margin } = BaseWidget
const { y, height } = this
const swatchWidth = 40
const swatchHeight = height - 6
const swatchRadius = swatchHeight / 2
const rightPadding = 10
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
// Swatch fixed on the right
const swatchX = width - margin - rightPadding - swatchWidth
const swatchY = y + 3
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
// Draw color swatch as rounded pill
ctx.beginPath()
ctx.roundRect(swatchX, swatchY, swatchWidth, swatchHeight, swatchRadius)
ctx.fillStyle = this.value || '#000000'
ctx.fill()
ctx.strokeStyle = this.outline_color
ctx.strokeRect(15, y, width - 30, height)
// Draw label on the left
ctx.fillStyle = this.secondary_text_color
ctx.textAlign = 'left'
ctx.fillText(this.displayName, margin * 2 + 5, y + height * 0.7)
// Draw hex value to the left of swatch
ctx.fillStyle = this.text_color
ctx.textAlign = 'right'
ctx.fillText(this.value || '#000000', swatchX - 8, y + height * 0.7)
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
const text = `Color: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick({ e, node, canvas }: WidgetEventOptions): void {
const input = getColorInput()
input.value = this.value || '#000000'
input.style.left = `${e.clientX}px`
input.style.top = `${e.clientY}px`
input.addEventListener(
'change',
() => {
this.setValue(input.value, { e, node, canvas })
canvas.setDirty(true)
},
{ once: true }
)
// Wait for next frame else Chrome doesn't render the color picker at the mouse
// Firefox always opens it in top left of window on Windows
requestAnimationFrame(() => input.click())
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -107,7 +107,6 @@
"modelUploaded": "تم استيراد النموذج بنجاح.",
"noAssetsFound": "لم يتم العثور على أصول",
"noModelsInFolder": "لا توجد {type} متاحة في هذا المجلد",
"noResultsCanImport": "حاول تعديل البحث أو عوامل التصفية.\nيمكنك أيضًا إضافة النماذج باستخدام زر \"استيراد\" أعلاه.",
"noValidSourceDetected": "لم يتم اكتشاف مصدر استيراد صالح",
"notSureLeaveAsIs": "لست متأكدًا؟ فقط اتركه كما هو",
"onlyCivitaiUrlsSupported": "يتم دعم روابط Civitai فقط",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "الإجراءات",
"allMissingNodesInstalled": "تم تثبيت جميع العقد المفقودة بنجاح",
"applyChanges": "تطبيق التغييرات",
"basicInfo": "معلومات أساسية",
"changingVersion": "تغيير الإصدار من {from} إلى {to}",
"clickToFinishSetup": "انقر",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "الرخصة",
"loadingVersions": "جاري تحميل الإصدارات...",
"mixedSelectionMessage": "لا يمكن تنفيذ إجراء جماعي على تحديد مختلط",
"nav": {
"allExtensions": "جميع الإضافات",
"allInWorkflow": "الكل في: {workflowName}",
"allInstalled": "جميع المثبتة",
"conflicting": "تعارض",
"inWorkflowSection": "في سير العمل",
"installedSection": "المثبتة",
"missingNodes": "عقد مفقودة",
"notInstalled": "غير مثبت",
"updatesAvailable": "تحديثات متوفرة"
},
"nightlyVersion": "ليلي",
"noDescription": "لا يوجد وصف متاح",
"noNodesFound": "لم يتم العثور على عقد",
"noNodesFoundDescription": "لم يمكن تحليل عقد الحزمة، أو أن الحزمة هي امتداد للواجهة فقط ولا تحتوي على أي عقد.",
"noResultsFound": "لم يتم العثور على نتائج مطابقة لبحثك.",
"nodePack": "حزمة العقد",
"nodePackInfo": "معلومات حزمة العقد",
"notAvailable": "غير متوفر",
"packsSelected": "الحزم المحددة",
"repository": "المستودع",
@@ -1352,7 +1337,6 @@
"restartingBackend": "جاري إعادة تشغيل الخلفية لتطبيق التغييرات...",
"searchPlaceholder": "بحث",
"selectVersion": "اختر الإصدار",
"selected": "المحدد",
"sort": {
"created": "الأحدث",
"downloads": "الأكثر شيوعاً",

View File

@@ -282,10 +282,6 @@
},
"manager": {
"title": "Nodes Manager",
"nodePackInfo": "Node Pack Info",
"basicInfo": "Basic Info",
"actions": "Actions",
"selected": "Selected",
"legacyMenuNotAvailable": "Legacy manager menu is not available, defaulting to the new manager menu.",
"legacyManagerUI": "Use Legacy UI",
"legacyManagerUIDescription": "To use the legacy Manager UI, start ComfyUI with --enable-manager-legacy-ui",
@@ -299,17 +295,6 @@
"changingVersion": "Changing version from {from} to {to}",
"dependencies": "Dependencies",
"inWorkflow": "In Workflow",
"nav": {
"allExtensions": "All Extensions",
"notInstalled": "Not Installed",
"installedSection": "INSTALLED",
"allInstalled": "All installed",
"updatesAvailable": "Updates Available",
"conflicting": "Conflicting",
"inWorkflowSection": "IN WORKFLOW",
"allInWorkflow": "All in: {workflowName}",
"missingNodes": "Missing Nodes"
},
"infoPanelEmpty": "Click an item to see the info",
"applyChanges": "Apply Changes",
"restartToApplyChanges": "To apply changes, please restart ComfyUI",
@@ -735,6 +720,7 @@
"groupBy": "Group By",
"sortMode": "Sort Mode",
"resetView": "Reset View to Default",
"filterCategory": "Filter category",
"groupStrategies": {
"category": "Category",
"categoryDesc": "Group by node category",
@@ -756,7 +742,6 @@
"title": "Queue Progress",
"total": "Total: {percent}",
"colonPercent": ": {percent}",
"inlineTotalLabel": "Total",
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"viewList": "List view",
@@ -2474,10 +2459,9 @@
"allModels": "All Models",
"byType": "By type",
"emptyImported": {
"canImport": "No imported models yet. Click \"Import\" to add your own.",
"canImport": "No imported models yet. Click \"Import Model\" to add your own.",
"restricted": "Personal models are only available at Creator tier and above."
},
"noResultsCanImport": "Try adjusting your search or filters.\nYou can also add models using the \"Import\" button above.",
"imported": "Imported",
"assetCollection": "Asset collection",
"assets": "Assets",
@@ -2545,7 +2529,7 @@
"tagsPlaceholder": "e.g., models, checkpoint",
"tryAdjustingFilters": "Try adjusting your search or filters",
"unknown": "Unknown",
"unsupportedUrlSource": "This URL is not supported. Use a direct model link from {sources}. See the how-to videos below for help.",
"unsupportedUrlSource": "Only URLs from {sources} are supported",
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upload": "Import",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Modelo importado correctamente.",
"noAssetsFound": "No se encontraron recursos",
"noModelsInFolder": "No hay {type} disponibles en esta carpeta",
"noResultsCanImport": "Intenta ajustar tu búsqueda o filtros.\nTambién puedes añadir modelos usando el botón \"Importar\" de arriba.",
"noValidSourceDetected": "No se detectó una fuente de importación válida",
"notSureLeaveAsIs": "¿No estás seguro? Déjalo como está",
"onlyCivitaiUrlsSupported": "Solo se admiten URLs de Civitai",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Acciones",
"allMissingNodesInstalled": "Todos los nodos faltantes se han instalado exitosamente",
"applyChanges": "Aplicar Cambios",
"basicInfo": "Información básica",
"changingVersion": "Cambiando versión de {from} a {to}",
"clickToFinishSetup": "Haz clic",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Licencia",
"loadingVersions": "Cargando versiones...",
"mixedSelectionMessage": "No se puede realizar acción masiva en selección mixta",
"nav": {
"allExtensions": "Todas las extensiones",
"allInWorkflow": "Todo en: {workflowName}",
"allInstalled": "Todo instalado",
"conflicting": "En conflicto",
"inWorkflowSection": "EN EL FLUJO DE TRABAJO",
"installedSection": "INSTALADO",
"missingNodes": "Nodos faltantes",
"notInstalled": "No instalado",
"updatesAvailable": "Actualizaciones disponibles"
},
"nightlyVersion": "Nocturna",
"noDescription": "No hay descripción disponible",
"noNodesFound": "No se encontraron nodos",
"noNodesFoundDescription": "Los nodos del paquete no se pudieron analizar, o el paquete es solo una extensión de frontend y no tiene ningún nodo.",
"noResultsFound": "No se encontraron resultados que coincidan con tu búsqueda.",
"nodePack": "Paquete de Nodos",
"nodePackInfo": "Información del paquete de nodos",
"notAvailable": "No Disponible",
"packsSelected": "Paquetes Seleccionados",
"repository": "Repositorio",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Reiniciando backend para aplicar cambios...",
"searchPlaceholder": "Buscar",
"selectVersion": "Seleccionar Versión",
"selected": "Seleccionado",
"sort": {
"created": "Más reciente",
"downloads": "Más Popular",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "مدل با موفقیت وارد شد.",
"noAssetsFound": "هیچ دارایی‌ای یافت نشد",
"noModelsInFolder": "هیچ {type} در این پوشه موجود نیست",
"noResultsCanImport": "جستجو یا فیلترهای خود را تغییر دهید.\nهمچنین می‌توانید مدل‌ها را با استفاده از دکمه «وارد کردن» در بالا اضافه کنید.",
"noValidSourceDetected": "هیچ منبع واردات معتبری شناسایی نشد",
"notSureLeaveAsIs": "مطمئن نیستید؟ همین را باقی بگذارید",
"onlyCivitaiUrlsSupported": "فقط URLهای Civitai پشتیبانی می‌شوند",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "اقدامات",
"allMissingNodesInstalled": "همه نودهای مفقود با موفقیت نصب شدند",
"applyChanges": "اعمال تغییرات",
"basicInfo": "اطلاعات پایه",
"changingVersion": "تغییر نسخه از {from} به {to}",
"clickToFinishSetup": "کلیک کنید",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "مجوز",
"loadingVersions": "در حال بارگذاری نسخه‌ها...",
"mixedSelectionMessage": "امکان انجام عملیات گروهی روی انتخاب ترکیبی وجود ندارد",
"nav": {
"allExtensions": "همه افزونه‌ها",
"allInWorkflow": "همه در: {workflowName}",
"allInstalled": "همه نصب شده‌ها",
"conflicting": "دارای تداخل",
"inWorkflowSection": "در Workflow",
"installedSection": "نصب شده",
"missingNodes": "Nodeهای مفقود",
"notInstalled": "نصب نشده",
"updatesAvailable": "به‌روزرسانی‌های موجود"
},
"nightlyVersion": "نسخه nightly",
"noDescription": "توضیحی موجود نیست",
"noNodesFound": "نودی یافت نشد",
"noNodesFoundDescription": "نودهای این بسته قابل تجزیه نبودند یا این بسته فقط یک افزونه فرانت‌اند است و نودی ندارد.",
"noResultsFound": "نتیجه‌ای مطابق با جستجوی شما یافت نشد.",
"nodePack": "بسته نود",
"nodePackInfo": "اطلاعات Node Pack",
"notAvailable": "در دسترس نیست",
"packsSelected": "بسته انتخاب شد",
"repository": "مخزن",
@@ -1352,7 +1337,6 @@
"restartingBackend": "در حال راه‌اندازی مجدد backend برای اعمال تغییرات...",
"searchPlaceholder": "جستجو",
"selectVersion": "انتخاب نسخه",
"selected": "انتخاب شده",
"sort": {
"created": "جدیدترین",
"downloads": "محبوب‌ترین",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Modèle importé avec succès.",
"noAssetsFound": "Aucune ressource trouvée",
"noModelsInFolder": "Aucun {type} disponible dans ce dossier",
"noResultsCanImport": "Essayez dajuster votre recherche ou vos filtres.\nVous pouvez également ajouter des modèles en utilisant le bouton « Importer » ci-dessus.",
"noValidSourceDetected": "Aucune source d'importation valide détectée",
"notSureLeaveAsIs": "Vous n'êtes pas sûr ? Laissez tel quel",
"onlyCivitaiUrlsSupported": "Seules les URL Civitai sont prises en charge",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Actions",
"allMissingNodesInstalled": "Tous les nœuds manquants ont été installés avec succès",
"applyChanges": "Appliquer les modifications",
"basicInfo": "Informations de base",
"changingVersion": "Changement de version de {from} à {to}",
"clickToFinishSetup": "Cliquez",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Licence",
"loadingVersions": "Chargement des versions...",
"mixedSelectionMessage": "Impossible d'effectuer une action groupée sur une sélection mixte",
"nav": {
"allExtensions": "Toutes les extensions",
"allInWorkflow": "Tout dans : {workflowName}",
"allInstalled": "Tout installé",
"conflicting": "En conflit",
"inWorkflowSection": "DANS LE FLUX DE TRAVAIL",
"installedSection": "INSTALLÉ",
"missingNodes": "Nœuds manquants",
"notInstalled": "Non installé",
"updatesAvailable": "Mises à jour disponibles"
},
"nightlyVersion": "Nocturne",
"noDescription": "Aucune description disponible",
"noNodesFound": "Aucun nœud trouvé",
"noNodesFoundDescription": "Les nœuds du pack n'ont pas pu être analysés, ou le pack est une extension frontend uniquement et n'a pas de nœuds.",
"noResultsFound": "Aucun résultat trouvé correspondant à votre recherche.",
"nodePack": "Pack de Nœuds",
"nodePackInfo": "Informations sur le pack de nœuds",
"notAvailable": "Non disponible",
"packsSelected": "Packs sélectionnés",
"repository": "Référentiel",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Redémarrage du backend pour appliquer les modifications...",
"searchPlaceholder": "Recherche",
"selectVersion": "Sélectionner la version",
"selected": "Sélectionné",
"sort": {
"created": "Le plus récent",
"downloads": "Le plus populaire",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "モデルが正常にインポートされました。",
"noAssetsFound": "アセットが見つかりません",
"noModelsInFolder": "このフォルダには{type}がありません",
"noResultsCanImport": "検索やフィルターを調整してみてください。\nまた、上の「インポート」ボタンからモデルを追加することもできます。",
"noValidSourceDetected": "有効なインポート元が検出されませんでした",
"notSureLeaveAsIs": "分からない場合はそのままにしてください",
"onlyCivitaiUrlsSupported": "CivitaiのURLのみサポートされています",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "アクション",
"allMissingNodesInstalled": "すべての不足しているノードが正常にインストールされました",
"applyChanges": "変更を適用",
"basicInfo": "基本情報",
"changingVersion": "バージョンを {from} から {to} に変更",
"clickToFinishSetup": "クリック",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "ライセンス",
"loadingVersions": "バージョンを読み込んでいます...",
"mixedSelectionMessage": "混在した選択では一括操作を実行できません",
"nav": {
"allExtensions": "すべての拡張機能",
"allInWorkflow": "{workflowName} 内のすべて",
"allInstalled": "すべてインストール済み",
"conflicting": "競合",
"inWorkflowSection": "ワークフロー内",
"installedSection": "インストール済み",
"missingNodes": "不足しているノード",
"notInstalled": "未インストール",
"updatesAvailable": "アップデートあり"
},
"nightlyVersion": "ナイトリー",
"noDescription": "説明はありません",
"noNodesFound": "ノードが見つかりません",
"noNodesFoundDescription": "パックのノードは解析できなかったか、パックがフロントエンドの拡張機能のみでノードがない可能性があります。",
"noResultsFound": "検索に一致する結果が見つかりませんでした。",
"nodePack": "ノードパック",
"nodePackInfo": "ノードパック情報",
"notAvailable": "利用不可",
"packsSelected": "選択したパック",
"repository": "リポジトリ",
@@ -1352,7 +1337,6 @@
"restartingBackend": "変更を適用するためにバックエンドを再起動しています...",
"searchPlaceholder": "検索",
"selectVersion": "バージョンを選択",
"selected": "選択済み",
"sort": {
"created": "最新",
"downloads": "最も人気",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "모델이 성공적으로 가져와졌습니다.",
"noAssetsFound": "에셋을 찾을 수 없습니다",
"noModelsInFolder": "이 폴더에 사용 가능한 {type}이(가) 없습니다",
"noResultsCanImport": "검색어나 필터를 조정해보세요.\n또는 위의 \"가져오기\" 버튼을 사용해 모델을 추가할 수 있습니다.",
"noValidSourceDetected": "유효한 가져오기 소스를 감지하지 못했습니다",
"notSureLeaveAsIs": "잘 모르겠다면 그대로 두세요",
"onlyCivitaiUrlsSupported": "Civitai URL만 지원됩니다",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "작업",
"allMissingNodesInstalled": "누락된 모든 노드가 성공적으로 설치되었습니다",
"applyChanges": "변경사항 적용",
"basicInfo": "기본 정보",
"changingVersion": "{from}에서 {to}(으)로 버전 변경 중",
"clickToFinishSetup": "클릭",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "라이선스",
"loadingVersions": "버전 로딩 중...",
"mixedSelectionMessage": "혼합 선택에 대해 일괄 작업을 수행할 수 없습니다",
"nav": {
"allExtensions": "모든 확장 프로그램",
"allInWorkflow": "모두: {workflowName}",
"allInstalled": "모두 설치됨",
"conflicting": "충돌",
"inWorkflowSection": "워크플로우 내",
"installedSection": "설치됨",
"missingNodes": "누락된 노드",
"notInstalled": "미설치",
"updatesAvailable": "업데이트 가능"
},
"nightlyVersion": "최신 테스트 버전(nightly)",
"noDescription": "설명이 없습니다",
"noNodesFound": "노드를 찾을 수 없습니다",
"noNodesFoundDescription": "팩의 노드를 파싱할 수 없거나, 팩이 프론트엔드 확장만을 가지고 있어서 노드가 없습니다.",
"noResultsFound": "검색과 일치하는 결과가 없습니다.",
"nodePack": "노드 팩",
"nodePackInfo": "노드 팩 정보",
"notAvailable": "사용 불가",
"packsSelected": "선택한 노드 팩",
"repository": "저장소",
@@ -1352,7 +1337,6 @@
"restartingBackend": "변경사항을 적용하기 위해 백엔드를 다시 시작하는 중...",
"searchPlaceholder": "검색",
"selectVersion": "버전 선택",
"selected": "선택됨",
"sort": {
"created": "최신",
"downloads": "가장 인기 있는",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Modelo importado com sucesso.",
"noAssetsFound": "Nenhum ativo encontrado",
"noModelsInFolder": "Nenhum {type} disponível nesta pasta",
"noResultsCanImport": "Tente ajustar sua busca ou filtros.\nVocê também pode adicionar modelos usando o botão \"Importar\" acima.",
"noValidSourceDetected": "Nenhuma fonte de importação válida detectada",
"notSureLeaveAsIs": "Não tem certeza? Deixe como está",
"onlyCivitaiUrlsSupported": "Apenas URLs do Civitai são suportadas",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Ações",
"allMissingNodesInstalled": "Todos os nodes ausentes foram instalados com sucesso",
"applyChanges": "Aplicar Alterações",
"basicInfo": "Informações Básicas",
"changingVersion": "Alterando versão de {from} para pt-BR",
"clickToFinishSetup": "Clique",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Licença",
"loadingVersions": "Carregando versões...",
"mixedSelectionMessage": "Não é possível realizar ação em massa em seleção mista",
"nav": {
"allExtensions": "Todas as Extensões",
"allInWorkflow": "Todos em: {workflowName}",
"allInstalled": "Todos Instalados",
"conflicting": "Conflitante",
"inWorkflowSection": "NO FLUXO DE TRABALHO",
"installedSection": "INSTALADO",
"missingNodes": "Nós Ausentes",
"notInstalled": "Não Instalado",
"updatesAvailable": "Atualizações Disponíveis"
},
"nightlyVersion": "Noturna",
"noDescription": "Nenhuma descrição disponível",
"noNodesFound": "Nenhum node encontrado",
"noNodesFoundDescription": "Os nodes do pacote não puderam ser analisados ou o pacote é apenas uma extensão de frontend e não possui nodes.",
"noResultsFound": "Nenhum resultado encontrado para sua busca.",
"nodePack": "Node Pack",
"nodePackInfo": "Informações do Pacote de Nós",
"notAvailable": "Não Disponível",
"packsSelected": "pacotes selecionados",
"repository": "Repositório",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Reiniciando backend para aplicar as alterações...",
"searchPlaceholder": "Buscar",
"selectVersion": "Selecionar Versão",
"selected": "Selecionado",
"sort": {
"created": "Mais Novos",
"downloads": "Mais Populares",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Модель успешно импортирована.",
"noAssetsFound": "Ресурсы не найдены",
"noModelsInFolder": "Нет {type} в этой папке",
"noResultsCanImport": "Попробуйте изменить параметры поиска или фильтры.\nВы также можете добавить модели с помощью кнопки «Импортировать» выше.",
"noValidSourceDetected": "Не обнаружен действительный источник импорта",
"notSureLeaveAsIs": "Не уверены? Просто оставьте как есть",
"onlyCivitaiUrlsSupported": "Поддерживаются только ссылки Civitai",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Действия",
"allMissingNodesInstalled": "Все отсутствующие ноды успешно установлены",
"applyChanges": "Применить изменения",
"basicInfo": "Основная информация",
"changingVersion": "Изменение версии с {from} на {to}",
"clickToFinishSetup": "Нажмите",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Лицензия",
"loadingVersions": "Загрузка версий...",
"mixedSelectionMessage": "Невозможно выполнить массовое действие для смешанного выбора",
"nav": {
"allExtensions": "Все расширения",
"allInWorkflow": "Все в: {workflowName}",
"allInstalled": "Все установленные",
"conflicting": "Конфликтующие",
"inWorkflowSection": "В РАБОЧЕМ ПРОЦЕССЕ",
"installedSection": "УСТАНОВЛЕНО",
"missingNodes": "Отсутствующие узлы",
"notInstalled": "Не установлено",
"updatesAvailable": "Доступны обновления"
},
"nightlyVersion": "Ночная",
"noDescription": "Описание отсутствует",
"noNodesFound": "Узлы не найдены",
"noNodesFoundDescription": "Узлы пакета не могут быть проанализированы, или пакет является только расширением интерфейса и не имеет узлов.",
"noResultsFound": "По вашему запросу ничего не найдено.",
"nodePack": "Пакет Узлов",
"nodePackInfo": "Информация о пакете узлов",
"notAvailable": "Недоступно",
"packsSelected": "Выбрано пакетов",
"repository": "Репозиторий",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Перезапуск бэкенда для применения изменений...",
"searchPlaceholder": "Поиск",
"selectVersion": "Выберите версию",
"selected": "Выбрано",
"sort": {
"created": "Новейшие",
"downloads": "Самые популярные",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Model başarıyla içe aktarıldı.",
"noAssetsFound": "Varlık bulunamadı",
"noModelsInFolder": "Bu klasörde {type} mevcut değil",
"noResultsCanImport": "Aramanızı veya filtrelerinizi ayarlamayı deneyin.\nAyrıca yukarıdaki \"İçe Aktar\" butonunu kullanarak modeller ekleyebilirsiniz.",
"noValidSourceDetected": "Geçerli bir içe aktarma kaynağı tespit edilmedi",
"notSureLeaveAsIs": "Emin değil misiniz? Olduğu gibi bırakın",
"onlyCivitaiUrlsSupported": "Yalnızca Civitai URL'leri destekleniyor",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Eylemler",
"allMissingNodesInstalled": "Tüm eksik düğümler başarıyla yüklendi",
"applyChanges": "Değişiklikleri Uygula",
"basicInfo": "Temel Bilgiler",
"changingVersion": "Sürüm {from} sürümünden {to} sürümüne değiştiriliyor",
"clickToFinishSetup": "Kurulumu tamamlamak için tıklayın",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Lisans",
"loadingVersions": "Sürümler yükleniyor...",
"mixedSelectionMessage": "Karışık seçim üzerinde toplu işlem yapılamaz",
"nav": {
"allExtensions": "Tüm Eklentiler",
"allInWorkflow": "Tümü: {workflowName}",
"allInstalled": "Tümü yüklü",
"conflicting": "Çakışan",
"inWorkflowSection": "İŞ AKIŞINDA",
"installedSection": "YÜKLÜ",
"missingNodes": "Eksik Düğümler",
"notInstalled": "Yüklü Değil",
"updatesAvailable": "Güncellemeler Mevcut"
},
"nightlyVersion": "Gecelik",
"noDescription": "Açıklama yok",
"noNodesFound": "Düğüm bulunamadı",
"noNodesFoundDescription": "Paketin düğümleri ya ayrıştırılamadı ya da paket yalnızca bir ön uç uzantısı ve herhangi bir düğüme sahip değil.",
"noResultsFound": "Aramanızla eşleşen sonuç bulunamadı.",
"nodePack": "Düğüm Paketi",
"nodePackInfo": "Düğüm Paketi Bilgisi",
"notAvailable": "Mevcut Değil",
"packsSelected": "paket seçildi",
"repository": "Depo",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Değişiklikleri uygulamak için arka uç yeniden başlatılıyor...",
"searchPlaceholder": "Ara",
"selectVersion": "Sürüm Seç",
"selected": "Seçildi",
"sort": {
"created": "En Yeni",
"downloads": "En Popüler",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "模型匯入成功。",
"noAssetsFound": "找不到資產",
"noModelsInFolder": "此資料夾中沒有可用的 {type}",
"noResultsCanImport": "請嘗試調整搜尋或篩選條件。\n你也可以使用上方的「匯入」按鈕新增模型。",
"noValidSourceDetected": "未偵測到有效的匯入來源",
"notSureLeaveAsIs": "不確定?請保持原樣",
"onlyCivitaiUrlsSupported": "僅支援 Civitai 的網址",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "操作",
"allMissingNodesInstalled": "所有缺少的節點已成功安裝",
"applyChanges": "套用變更",
"basicInfo": "基本資訊",
"changingVersion": "正在將版本從 {from} 變更為 {to}",
"clickToFinishSetup": "點擊",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "授權條款",
"loadingVersions": "正在載入版本...",
"mixedSelectionMessage": "無法對混合選取執行批次操作",
"nav": {
"allExtensions": "所有擴充功能",
"allInWorkflow": "全部於:{workflowName}",
"allInstalled": "全部已安裝",
"conflicting": "有衝突",
"inWorkflowSection": "工作流程中",
"installedSection": "已安裝",
"missingNodes": "缺少節點",
"notInstalled": "未安裝",
"updatesAvailable": "有可用更新"
},
"nightlyVersion": "每夜建置版",
"noDescription": "沒有可用的說明",
"noNodesFound": "找不到任何節點",
"noNodesFoundDescription": "此套件的節點無法解析,或此套件僅為前端擴充功能,沒有任何節點。",
"noResultsFound": "找不到符合搜尋條件的結果。",
"nodePack": "節點包",
"nodePackInfo": "節點包資訊",
"notAvailable": "不可用",
"packsSelected": "已選擇套件",
"repository": "儲存庫",
@@ -1352,7 +1337,6 @@
"restartingBackend": "正在重新啟動後端以套用變更...",
"searchPlaceholder": "搜尋",
"selectVersion": "選擇版本",
"selected": "已選取",
"sort": {
"created": "最新上架",
"downloads": "最受歡迎",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "模型导入成功!🎉",
"noAssetsFound": "未找到资产",
"noModelsInFolder": "此文件夹中没有可用的{type}",
"noResultsCanImport": "尝试调整您的搜索或筛选条件。\n您也可以使用上方的“导入”按钮添加模型。",
"noValidSourceDetected": "检测不到有效的导入源",
"notSureLeaveAsIs": "不确定?那就放着不管吧",
"onlyCivitaiUrlsSupported": "仅支持 Civitai 链接",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "操作",
"allMissingNodesInstalled": "所有缺失节点已成功安装",
"applyChanges": "应用更改",
"basicInfo": "基本信息",
"changingVersion": "将版本从 {from} 更改为 {to}",
"clickToFinishSetup": "点击",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "许可证",
"loadingVersions": "正在加载版本...",
"mixedSelectionMessage": "无法对混合选择执行批量操作",
"nav": {
"allExtensions": "全部扩展",
"allInWorkflow": "全部在:{workflowName}",
"allInstalled": "全部已安装",
"conflicting": "存在冲突",
"inWorkflowSection": "工作流中",
"installedSection": "已安装",
"missingNodes": "缺失节点",
"notInstalled": "未安装",
"updatesAvailable": "有可用更新"
},
"nightlyVersion": "每夜",
"noDescription": "无可用描述",
"noNodesFound": "未找到节点",
"noNodesFoundDescription": "无法解析包的节点,或者该包仅为前端扩展,没有任何节点。",
"noResultsFound": "未找到符合您搜索的结果。",
"nodePack": "节点包",
"nodePackInfo": "节点包信息",
"notAvailable": "不可用",
"packsSelected": "选定的包",
"repository": "仓库",
@@ -1352,7 +1337,6 @@
"restartingBackend": "正在重启后端以应用更改...",
"searchPlaceholder": "搜索",
"selectVersion": "选择版本",
"selected": "已选择",
"sort": {
"created": "最新",
"downloads": "最受欢迎",

View File

@@ -31,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, {

View File

@@ -1,5 +1,4 @@
import { mount } from '@vue/test-utils'
import { computed } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -7,18 +6,6 @@ import ActiveJobCard from './ActiveMediaAssetCard.vue'
import type { JobListItem } from '@/composables/queue/useJobList'
vi.mock('@/composables/queue/useJobActions', () => ({
useJobActions: () => ({
cancelAction: {
icon: 'icon-[lucide--x]',
label: 'Cancel',
variant: 'destructive'
},
canCancelJob: computed(() => false),
runCancelJob: vi.fn()
})
}))
vi.mock('@/composables/useProgressBarBackground', () => ({
useProgressBarBackground: () => ({
progressBarPrimaryClass: 'bg-blue-500',

View File

@@ -1,13 +1,8 @@
<template>
<div
role="status"
tabindex="0"
:aria-label="t('sideToolbar.activeJobStatus', { status: statusText })"
class="flex flex-col gap-2 p-2 rounded-lg"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
@focusin="hovered = true"
@focusout="hovered = false"
>
<!-- Thumbnail -->
<div class="relative aspect-square overflow-hidden rounded-lg">
@@ -39,17 +34,6 @@
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
/>
</div>
<!-- Cancel button overlay -->
<Button
v-if="hovered && canCancelJob"
variant="destructive"
size="icon"
:aria-label="cancelAction.label"
class="absolute top-2 right-2"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" />
</Button>
</div>
<!-- Footer: Progress bar or status text -->
@@ -77,20 +61,15 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
const { job } = defineProps<{ job: JobListItem }>()
const { t } = useI18n()
const hovered = ref(false)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(() => job)
const { progressBarPrimaryClass, hasProgressPercent, progressPercentStyle } =
useProgressBarBackground()

View File

@@ -211,11 +211,7 @@ const shouldShowLeftPanel = computed(() => {
})
const emptyMessage = computed(() => {
if (!isImportedSelected.value) {
return isUploadButtonEnabled.value
? t('assetBrowser.noResultsCanImport')
: undefined
}
if (!isImportedSelected.value) return undefined
return isUploadButtonEnabled.value
? t('assetBrowser.emptyImported.canImport')

View File

@@ -10,7 +10,7 @@ const createAssetData = (
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem => ({
...baseAsset,
secondaryText:
description:
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
badges: [
{ label: 'checkpoints', type: 'type' },
@@ -131,21 +131,20 @@ export const EdgeCases: Story = {
// Default case for comparison
createAssetData({
name: 'Complete Data',
secondaryText: 'Asset with all data present for comparison'
description: 'Asset with all data present for comparison'
}),
// No badges
createAssetData({
id: 'no-badges',
name: 'No Badges',
secondaryText:
'Testing graceful handling when badges are not provided',
description: 'Testing graceful handling when badges are not provided',
badges: []
}),
// No stars
createAssetData({
id: 'no-stars',
name: 'No Stars',
secondaryText: 'Testing missing stars data gracefully',
description: 'Testing missing stars data gracefully',
stats: {
downloadCount: '1.8k',
formattedDate: '3/15/25'
@@ -155,7 +154,7 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-downloads',
name: 'No Downloads',
secondaryText: 'Testing missing downloads data gracefully',
description: 'Testing missing downloads data gracefully',
stats: {
stars: '4.2k',
formattedDate: '3/15/25'
@@ -165,7 +164,7 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-date',
name: 'No Date',
secondaryText: 'Testing missing date data gracefully',
description: 'Testing missing date data gracefully',
stats: {
stars: '4.2k',
downloadCount: '1.8k'
@@ -175,21 +174,21 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-stats',
name: 'No Stats',
secondaryText: 'Testing when all stats are missing',
description: 'Testing when all stats are missing',
stats: {}
}),
// Long secondaryText
// Long description
createAssetData({
id: 'long-desc',
name: 'Long Description',
secondaryText:
description:
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
}),
// Minimal data
createAssetData({
id: 'minimal',
name: 'Minimal',
secondaryText: 'Basic model',
description: 'Basic model',
tags: ['models'],
badges: [],
stats: {}

View File

@@ -82,14 +82,14 @@
</h3>
<p
:id="descId"
v-tooltip.top="{ value: asset.secondaryText, showDelay: tooltipDelay }"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
:class="
cn(
'm-0 text-sm line-clamp-2 [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
)
"
>
{{ asset.secondaryText }}
{{ asset.description }}
</p>
<div class="flex items-center justify-between gap-2 mt-auto">
<div class="flex gap-3 text-xs text-muted-foreground">

View File

@@ -21,7 +21,7 @@
<h3 class="mb-2 text-lg font-medium">
{{ emptyTitle ?? $t('assetBrowser.noAssetsFound') }}
</h3>
<p class="text-sm whitespace-pre-wrap text-center">
<p class="text-sm">
{{ emptyMessage ?? $t('assetBrowser.tryAdjustingFilters') }}
</p>
</div>

View File

@@ -48,10 +48,6 @@
@image-loaded="handleImageLoaded"
/>
<LoadingOverlay :loading="isDeleting">
<i class="icon-[lucide--trash-2] size-5" />
</LoadingOverlay>
<!-- Action buttons overlay (top-left) -->
<div
v-if="showActionsOverlay"
@@ -134,9 +130,7 @@ import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAssetsStore } from '@/stores/assetsStore'
import {
formatDuration,
formatSize,
@@ -173,13 +167,6 @@ const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
outputCount?: number
}>()
const assetsStore = useAssetsStore()
// Get deletion state from store
const isDeleting = computed(() =>
asset ? assetsStore.isAssetDeleting(asset.id) : false
)
const emit = defineEmits<{
click: []
zoom: [asset: AssetItem]
@@ -265,7 +252,7 @@ const metaInfo = computed(() => {
})
const showActionsOverlay = computed(() => {
if (loading || !asset || isDeleting.value) return false
if (loading || !asset) return false
return isHovered.value || selected || isVideoPlaying.value
})

View File

@@ -247,8 +247,8 @@ const contextMenuItems = computed<MenuItem[]>(() => {
icon: 'icon-[lucide--trash-2]',
command: async () => {
if (asset) {
const confirmed = await actions.deleteAssets(asset)
if (confirmed) {
const success = await actions.confirmDelete(asset)
if (success) {
emit('asset-deleted')
}
}

View File

@@ -34,7 +34,7 @@ describe('ModelInfoPanel', () => {
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
secondaryText: 'A test model description',
description: 'A test model description',
badges: [],
stats: {},
...overrides

View File

@@ -84,14 +84,14 @@ describe('useAssetBrowser', () => {
expect(result.name).toBe(apiAsset.name)
// Adds display properties
expect(result.secondaryText).toBe('test-asset.safetensors')
expect(result.description).toBe('Test model')
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
})
it('creates secondaryText from filename when metadata missing', () => {
it('creates fallback description from tags when metadata missing', () => {
const apiAsset = createApiAsset({
tags: ['models', 'loras'],
user_metadata: undefined
@@ -100,7 +100,7 @@ describe('useAssetBrowser', () => {
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.secondaryText).toBe('test-asset.safetensors')
expect(result.description).toBe('loras model')
})
it('removes category prefix from badge labels', () => {

View File

@@ -9,8 +9,8 @@ import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vu
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetDisplayName,
getAssetFilename
getAssetDescription,
getAssetDisplayName
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
@@ -70,7 +70,7 @@ type AssetBadge = {
// Display properties for transformed assets
export interface AssetDisplayItem extends AssetItem {
secondaryText: string
description: string
badges: AssetBadge[]
stats: {
formattedDate?: string
@@ -116,11 +116,15 @@ export function useAssetBrowser(
// Transform API asset to display asset
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
const secondaryText = getAssetFilename(asset)
// Extract description from metadata or create from tags
const typeTag = asset.tags.find((tag) => tag !== 'models')
const description =
getAssetDescription(asset) ||
`${typeTag || t('assetBrowser.unknown')} model`
// Create badges from tags and metadata
const badges: AssetBadge[] = []
const typeTag = asset.tags.find((tag) => tag !== 'models')
// Type badge from non-root tag
if (typeTag) {
// Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
@@ -148,7 +152,7 @@ export function useAssetBrowser(
return {
...asset,
secondaryText,
description,
badges,
stats
}

View File

@@ -4,8 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useMediaAssetActions } from './useMediaAssetActions'
// Use vi.hoisted to create a mutable reference for isCloud
const mockIsCloud = vi.hoisted(() => ({ value: false }))
@@ -114,7 +112,6 @@ function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
describe('useMediaAssetActions', () => {
beforeEach(() => {
vi.resetModules()
setActivePinia(createPinia())
vi.clearAllMocks()
capturedFilenames.values = []
@@ -128,6 +125,7 @@ describe('useMediaAssetActions', () => {
})
it('should use asset.name as filename', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -147,6 +145,7 @@ describe('useMediaAssetActions', () => {
})
it('should use asset_hash as filename when available', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -160,6 +159,7 @@ describe('useMediaAssetActions', () => {
})
it('should fall back to asset.name when asset_hash is not available', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -173,6 +173,7 @@ describe('useMediaAssetActions', () => {
})
it('should fall back to asset.name when asset_hash is null', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -194,6 +195,7 @@ describe('useMediaAssetActions', () => {
})
it('should use asset_hash for each asset', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const assets = [

View File

@@ -134,6 +134,71 @@ export function useMediaAssetActions() {
}
}
/**
* Show confirmation dialog and delete asset if confirmed
* @param asset The asset to delete
* @returns true if the asset was deleted, false otherwise
*/
const confirmDelete = async (asset: AssetItem): Promise<boolean> => {
const assetType = getAssetType(asset)
return new Promise((resolve) => {
dialogStore.showDialog({
key: 'delete-asset-confirmation',
title: t('mediaAsset.deleteAssetTitle'),
component: ConfirmationDialogContent,
props: {
message: t('mediaAsset.deleteAssetDescription'),
type: 'delete',
itemList: [asset.name],
onConfirm: async () => {
const success = await deleteAsset(asset, assetType)
resolve(success)
},
onCancel: () => {
resolve(false)
}
}
})
})
}
const deleteAsset = async (asset: AssetItem, assetType: string) => {
const assetsStore = useAssetsStore()
try {
// Perform the deletion
await deleteAssetApi(asset, assetType)
// Update the appropriate store based on asset type
if (assetType === 'output') {
await assetsStore.updateHistory()
} else {
await assetsStore.updateInputs()
}
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.assetDeletedSuccessfully'),
life: 2000
})
return true
} catch (error) {
console.error('Failed to delete asset:', error)
const errorMessage = error instanceof Error ? error.message : ''
const isCloudWarning = errorMessage.includes('Cloud')
toast.add({
severity: isCloudWarning ? 'warn' : 'error',
summary: isCloudWarning ? t('g.warning') : t('g.error'),
detail: errorMessage || t('mediaAsset.failedToDeleteAsset'),
life: 3000
})
return false
}
}
const copyJobId = async (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
@@ -515,44 +580,30 @@ export function useMediaAssetActions() {
}
/**
* Show confirmation dialog and delete asset(s) if confirmed
* @param assets Single asset or array of assets to delete
* @returns true if user confirmed and deletion was attempted, false if cancelled
* Delete multiple assets with confirmation dialog
* @param assets Array of assets to delete
*/
const deleteAssets = async (
assets: AssetItem | AssetItem[]
): Promise<boolean> => {
const assetArray = Array.isArray(assets) ? assets : [assets]
if (assetArray.length === 0) return false
const deleteMultipleAssets = async (assets: AssetItem[]) => {
if (!assets || assets.length === 0) return
const assetsStore = useAssetsStore()
const isSingle = assetArray.length === 1
return new Promise((resolve) => {
return new Promise<void>((resolve) => {
dialogStore.showDialog({
key: 'delete-assets-confirmation',
title: isSingle
? t('mediaAsset.deleteAssetTitle')
: t('mediaAsset.deleteSelectedTitle'),
key: 'delete-multiple-assets-confirmation',
title: t('mediaAsset.deleteSelectedTitle'),
component: ConfirmationDialogContent,
props: {
message: isSingle
? t('mediaAsset.deleteAssetDescription')
: t('mediaAsset.deleteSelectedDescription', {
count: assetArray.length
}),
message: t('mediaAsset.deleteSelectedDescription', {
count: assets.length
}),
type: 'delete',
itemList: assetArray.map((asset) => asset.name),
itemList: assets.map((asset) => asset.name),
onConfirm: async () => {
// Show loading overlay for all assets being deleted
assetArray.forEach((asset) =>
assetsStore.setAssetDeleting(asset.id, true)
)
try {
// Delete all assets using Promise.allSettled to track individual results
const results = await Promise.allSettled(
assetArray.map((asset) =>
assets.map((asset) =>
deleteAssetApi(asset, getAssetType(asset))
)
)
@@ -566,16 +617,16 @@ export function useMediaAssetActions() {
// Log failed deletions for debugging
failed.forEach((result, index) => {
console.warn(
`Failed to delete asset ${assetArray[index].name}:`,
`Failed to delete asset ${assets[index].name}:`,
result.reason
)
})
// Update stores after deletions
const hasOutputAssets = assetArray.some(
const hasOutputAssets = assets.some(
(a) => getAssetType(a) === 'output'
)
const hasInputAssets = assetArray.some(
const hasInputAssets = assets.some(
(a) => getAssetType(a) === 'input'
)
@@ -588,27 +639,25 @@ export function useMediaAssetActions() {
// Show appropriate feedback based on results
if (failed.length === 0) {
// All succeeded
toast.add({
severity: 'success',
summary: t('g.success'),
detail: isSingle
? t('mediaAsset.assetDeletedSuccessfully')
: t('mediaAsset.selection.assetsDeletedSuccessfully', {
count: succeeded
}),
detail: t('mediaAsset.selection.assetsDeletedSuccessfully', {
count: succeeded
}),
life: 2000
})
} else if (succeeded === 0) {
// All failed
toast.add({
severity: 'error',
summary: t('g.error'),
detail: isSingle
? t('mediaAsset.failedToDeleteAsset')
: t('mediaAsset.selection.failedToDeleteAssets'),
detail: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000
})
} else {
// Partial success (only possible with multiple assets)
// Partial success
toast.add({
severity: 'warn',
summary: t('g.warning'),
@@ -624,22 +673,15 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: isSingle
? t('mediaAsset.failedToDeleteAsset')
: t('mediaAsset.selection.failedToDeleteAssets'),
detail: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000
})
} finally {
// Hide loading overlay for all assets
assetArray.forEach((asset) =>
assetsStore.setAssetDeleting(asset.id, false)
)
}
resolve(true)
resolve()
},
onCancel: () => {
resolve(false)
resolve()
}
}
})
@@ -649,7 +691,9 @@ export function useMediaAssetActions() {
return {
downloadAsset,
downloadMultipleAssets,
deleteAssets,
confirmDelete,
deleteAsset,
deleteMultipleAssets,
copyJobId,
addWorkflow,
addMultipleToWorkflow,

View File

@@ -206,6 +206,49 @@ 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',
items: [
{
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,

View File

@@ -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,45 @@ 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',
items: [
{
item_id: `${billingCycle}_${tierKey}`,
item_name: planName,
item_category: 'subscription',
item_variant: billingCycle,
price: value,
quantity: 1
}
]
})
clearPendingSubscriptionPurchase()
}
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
@@ -194,6 +240,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
}

View File

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

View File

@@ -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()
}

View File

@@ -11,26 +11,11 @@ vi.mock('@/scripts/api', () => ({
}
}))
vi.stubGlobal('fetch', vi.fn())
global.fetch = vi.fn()
describe('refreshRemoteConfig', () => {
const mockConfig = { feature1: true, feature2: 'value' }
function mockSuccessResponse(config = mockConfig) {
return {
ok: true,
json: async () => config
} as Response
}
function mockErrorResponse(status: number, statusText: string) {
return {
ok: false,
status,
statusText
} as Response
}
beforeEach(() => {
vi.clearAllMocks()
remoteConfig.value = {}
@@ -39,7 +24,10 @@ describe('refreshRemoteConfig', () => {
describe('with auth (default)', () => {
it('uses api.fetchApi when useAuth is true', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: async () => mockConfig
} as Response)
await refreshRemoteConfig({ useAuth: true })
@@ -52,7 +40,10 @@ describe('refreshRemoteConfig', () => {
})
it('uses api.fetchApi by default', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: async () => mockConfig
} as Response)
await refreshRemoteConfig()
@@ -63,7 +54,10 @@ describe('refreshRemoteConfig', () => {
describe('without auth', () => {
it('uses raw fetch when useAuth is false', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockConfig
} as Response)
await refreshRemoteConfig({ useAuth: false })
@@ -78,9 +72,11 @@ describe('refreshRemoteConfig', () => {
describe('error handling', () => {
it('clears config on 401 response', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(
mockErrorResponse(401, 'Unauthorized')
)
vi.mocked(api.fetchApi).mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized'
} as Response)
await refreshRemoteConfig()
@@ -89,9 +85,11 @@ describe('refreshRemoteConfig', () => {
})
it('clears config on 403 response', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(
mockErrorResponse(403, 'Forbidden')
)
vi.mocked(api.fetchApi).mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden'
} as Response)
await refreshRemoteConfig()
@@ -107,20 +105,5 @@ describe('refreshRemoteConfig', () => {
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
})
it('preserves config on 500 response', async () => {
const existingConfig = { subscription_required: true }
remoteConfig.value = existingConfig
window.__CONFIG__ = existingConfig
vi.mocked(api.fetchApi).mockResolvedValue(
mockErrorResponse(500, 'Internal Server Error')
)
await refreshRemoteConfig()
expect(remoteConfig.value).toEqual(existingConfig)
expect(window.__CONFIG__).toEqual(existingConfig)
})
})
})

View File

@@ -1,6 +1,6 @@
import { api } from '@/scripts/api'
import { remoteConfig, remoteConfigState } from './remoteConfig'
import { remoteConfig } from './remoteConfig'
interface RefreshRemoteConfigOptions {
/**
@@ -12,12 +12,7 @@ interface RefreshRemoteConfigOptions {
/**
* Loads remote configuration from the backend /features endpoint
* and updates the reactive remoteConfig ref.
*
* Sets remoteConfigState to:
* - 'anonymous' when loaded without auth
* - 'authenticated' when loaded with auth
* - 'error' when load fails
* and updates the reactive remoteConfig ref
*/
export async function refreshRemoteConfig(
options: RefreshRemoteConfigOptions = {}
@@ -33,7 +28,6 @@ export async function refreshRemoteConfig(
const config = await response.json()
window.__CONFIG__ = config
remoteConfig.value = config
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
return
}
@@ -41,12 +35,10 @@ export async function refreshRemoteConfig(
if (response.status === 401 || response.status === 403) {
window.__CONFIG__ = {}
remoteConfig.value = {}
remoteConfigState.value = 'error'
}
} catch (error) {
console.error('Failed to fetch remote config:', error)
window.__CONFIG__ = {}
remoteConfig.value = {}
remoteConfigState.value = 'error'
}
}

View File

@@ -10,32 +10,10 @@
* This module is tree-shaken in OSS builds.
*/
import { computed, ref } from 'vue'
import { ref } from 'vue'
import type { RemoteConfig } from './types'
/**
* Load state for remote configuration.
* - 'unloaded': No config loaded yet
* - 'anonymous': Config loaded without auth (bootstrap)
* - 'authenticated': Config loaded with auth (user-specific flags available)
* - 'error': Failed to load config
*/
type RemoteConfigState = 'unloaded' | 'anonymous' | 'authenticated' | 'error'
/**
* Current load state of remote configuration
*/
export const remoteConfigState = ref<RemoteConfigState>('unloaded')
/**
* Whether the authenticated config has been loaded.
* Use this to gate access to user-specific feature flags like teamWorkspacesEnabled.
*/
export const isAuthenticatedConfigLoaded = computed(
() => remoteConfigState.value === 'authenticated'
)
/**
* Reactive remote configuration
* Updated whenever config is loaded from the server

View File

@@ -1,27 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
const STORAGE_KEY = 'Comfy.FeatureUsage'
describe('useFeatureUsageTracker', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
vi.resetModules()
})
afterEach(() => {
localStorage.clear()
})
it('initializes with zero count for new feature', () => {
const { useCount } = useFeatureUsageTracker('test-feature-1')
it('initializes with zero count for new feature', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount } = useFeatureUsageTracker('test-feature')
expect(useCount.value).toBe(0)
})
it('increments count on trackUsage', () => {
const { useCount, trackUsage } = useFeatureUsageTracker('test-feature-2')
it('increments count on trackUsage', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount, trackUsage } = useFeatureUsageTracker('test-feature')
expect(useCount.value).toBe(0)
@@ -32,12 +32,14 @@ describe('useFeatureUsageTracker', () => {
expect(useCount.value).toBe(2)
})
it('sets firstUsed only on first use', () => {
it('sets firstUsed only on first use', async () => {
vi.useFakeTimers()
const firstTs = 1000000
vi.setSystemTime(firstTs)
try {
const { usage, trackUsage } = useFeatureUsageTracker('test-feature-3')
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { usage, trackUsage } = useFeatureUsageTracker('test-feature')
trackUsage()
expect(usage.value?.firstUsed).toBe(firstTs)
@@ -50,10 +52,12 @@ describe('useFeatureUsageTracker', () => {
}
})
it('updates lastUsed on each use', () => {
it('updates lastUsed on each use', async () => {
vi.useFakeTimers()
try {
const { usage, trackUsage } = useFeatureUsageTracker('test-feature-4')
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { usage, trackUsage } = useFeatureUsageTracker('test-feature')
trackUsage()
const firstLastUsed = usage.value?.lastUsed ?? 0
@@ -67,9 +71,10 @@ describe('useFeatureUsageTracker', () => {
}
})
it('reset clears feature data', () => {
it('reset clears feature data', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount, trackUsage, reset } =
useFeatureUsageTracker('test-feature-5')
useFeatureUsageTracker('test-feature')
trackUsage()
trackUsage()
@@ -79,7 +84,8 @@ describe('useFeatureUsageTracker', () => {
expect(useCount.value).toBe(0)
})
it('tracks multiple features independently', () => {
it('tracks multiple features independently', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const featureA = useFeatureUsageTracker('feature-a')
const featureB = useFeatureUsageTracker('feature-b')
@@ -94,6 +100,8 @@ describe('useFeatureUsageTracker', () => {
it('persists to localStorage', async () => {
vi.useFakeTimers()
try {
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { trackUsage } = useFeatureUsageTracker('persisted-feature')
trackUsage()
@@ -106,7 +114,7 @@ describe('useFeatureUsageTracker', () => {
}
})
it('loads existing data from localStorage', () => {
it('loads existing data from localStorage', async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
@@ -114,6 +122,8 @@ describe('useFeatureUsageTracker', () => {
})
)
vi.resetModules()
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount } = useFeatureUsageTracker('existing-feature')
expect(useCount.value).toBe(5)

View File

@@ -1,259 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSurveyEligibility } from './useSurveyEligibility'
const SURVEY_STATE_KEY = 'Comfy.SurveyState'
const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage'
const mockDistribution = vi.hoisted(() => ({
isNightly: true,
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/distribution/types', () => ({
get isNightly() {
return mockDistribution.isNightly
},
get isCloud() {
return mockDistribution.isCloud
},
get isDesktop() {
return mockDistribution.isDesktop
}
}))
describe('useSurveyEligibility', () => {
const defaultConfig = {
featureId: 'test-feature',
typeformId: 'abc123'
}
beforeEach(() => {
localStorage.clear()
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'))
mockDistribution.isNightly = true
mockDistribution.isCloud = false
mockDistribution.isDesktop = false
})
afterEach(() => {
localStorage.clear()
vi.useRealTimers()
})
function setFeatureUsage(featureId: string, useCount: number) {
const existing = JSON.parse(localStorage.getItem(FEATURE_USAGE_KEY) ?? '{}')
existing[featureId] = {
useCount,
firstUsed: Date.now() - 1000,
lastUsed: Date.now()
}
localStorage.setItem(FEATURE_USAGE_KEY, JSON.stringify(existing))
}
describe('eligibility checks', () => {
it('is not eligible when not nightly', () => {
mockDistribution.isNightly = false
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible on cloud', () => {
mockDistribution.isCloud = true
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible on desktop', () => {
mockDistribution.isDesktop = true
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible below threshold', () => {
setFeatureUsage('test-feature', 2)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is eligible when all conditions met', () => {
setFeatureUsage('test-feature', 3)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
})
it('respects custom threshold', () => {
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility({
...defaultConfig,
triggerThreshold: 10
})
expect(isEligible.value).toBe(false)
})
it('is not eligible when survey already seen', () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: false,
seenSurveys: { 'test-feature': Date.now() }
})
)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible during global cooldown', () => {
setFeatureUsage('test-feature', 5)
const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: false,
seenSurveys: { 'other-feature': threeDaysAgo }
})
)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is eligible after global cooldown expires', () => {
setFeatureUsage('test-feature', 5)
const fiveDaysAgo = Date.now() - 5 * 24 * 60 * 60 * 1000
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: false,
seenSurveys: { 'other-feature': fiveDaysAgo }
})
)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
})
it('is not eligible when opted out', () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: true,
seenSurveys: {}
})
)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible when config disabled', () => {
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility({
...defaultConfig,
enabled: false
})
expect(isEligible.value).toBe(false)
})
})
describe('actions', () => {
it('markSurveyShown makes user ineligible', () => {
setFeatureUsage('test-feature', 5)
const { isEligible, markSurveyShown } =
useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
markSurveyShown()
expect(isEligible.value).toBe(false)
})
it('optOut prevents all future surveys', () => {
setFeatureUsage('test-feature', 5)
const { isEligible, optOut } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
optOut()
expect(isEligible.value).toBe(false)
})
it('resetState restores eligibility', () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: true,
seenSurveys: { 'test-feature': Date.now() }
})
)
const { isEligible, resetState } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
resetState()
expect(isEligible.value).toBe(true)
})
})
describe('config values', () => {
it('exposes delayMs from config', () => {
const { delayMs } = useSurveyEligibility({
...defaultConfig,
delayMs: 10000
})
expect(delayMs.value).toBe(10000)
})
})
describe('persistence', () => {
it('loads existing state from localStorage', () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: false,
seenSurveys: { 'test-feature': 1000 }
})
)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
})
})

View File

@@ -1,97 +0,0 @@
import { useStorage } from '@vueuse/core'
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
interface FeatureSurveyConfig {
/** Feature identifier. Must remain static after initialization. */
featureId: string
typeformId: string
triggerThreshold?: number
delayMs?: number
enabled?: boolean
}
interface SurveyState {
optedOut: boolean
seenSurveys: Record<string, number>
}
const STORAGE_KEY = 'Comfy.SurveyState'
const GLOBAL_COOLDOWN_MS = 4 * 24 * 60 * 60 * 1000 // 4 days
const DEFAULT_THRESHOLD = 3
const DEFAULT_DELAY_MS = 5000
export function useSurveyEligibility(
config: MaybeRefOrGetter<FeatureSurveyConfig>
) {
const state = useStorage<SurveyState>(STORAGE_KEY, {
optedOut: false,
seenSurveys: {}
})
const resolvedConfig = computed(() => toValue(config))
const { useCount } = useFeatureUsageTracker(resolvedConfig.value.featureId)
const threshold = computed(
() => resolvedConfig.value.triggerThreshold ?? DEFAULT_THRESHOLD
)
const delayMs = computed(
() => resolvedConfig.value.delayMs ?? DEFAULT_DELAY_MS
)
const isSurveyEnabled = computed(() => resolvedConfig.value.enabled ?? true)
const isNightlyLocalhost = computed(() => isNightly && !isCloud && !isDesktop)
const hasReachedThreshold = computed(() => useCount.value >= threshold.value)
const hasSeenSurvey = computed(
() => !!state.value.seenSurveys[resolvedConfig.value.featureId]
)
const isInGlobalCooldown = computed(() => {
const timestamps = Object.values(state.value.seenSurveys)
if (timestamps.length === 0) return false
const lastShown = Math.max(...timestamps)
return Date.now() - lastShown < GLOBAL_COOLDOWN_MS
})
const hasOptedOut = computed(() => state.value.optedOut)
const isEligible = computed(() => {
if (!isSurveyEnabled.value) return false
if (!isNightlyLocalhost.value) return false
if (!hasReachedThreshold.value) return false
if (hasSeenSurvey.value) return false
if (isInGlobalCooldown.value) return false
if (hasOptedOut.value) return false
return true
})
function markSurveyShown() {
state.value.seenSurveys[resolvedConfig.value.featureId] = Date.now()
}
function optOut() {
state.value.optedOut = true
}
function resetState() {
state.value = {
optedOut: false,
seenSurveys: {}
}
}
return {
isEligible,
delayMs,
markSurveyShown,
optOut,
resetState
}
}

View 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)
}

View File

@@ -1,10 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
startTopupTracking,
checkForCompletedTopup,
clearTopupTracking
} from '@/platform/telemetry/topupTracker'
import type * as TopupTrackerModule from '@/platform/telemetry/topupTracker'
import type { AuditLog } from '@/services/customerEventsService'
// Mock localStorage
@@ -29,15 +25,19 @@ vi.mock('@/platform/telemetry', () => ({
}))
describe('topupTracker', () => {
beforeEach(() => {
let topupTracker: typeof TopupTrackerModule
beforeEach(async () => {
vi.clearAllMocks()
// Dynamically import to ensure fresh module state
topupTracker = await import('@/platform/telemetry/topupTracker')
})
describe('startTopupTracking', () => {
it('should save current timestamp to localStorage', () => {
const beforeTimestamp = Date.now()
startTopupTracking()
topupTracker.startTopupTracking()
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'pending_topup_timestamp',
@@ -57,7 +57,7 @@ describe('topupTracker', () => {
it('should return false if no pending topup exists', () => {
mockLocalStorage.getItem.mockReturnValue(null)
const result = checkForCompletedTopup([])
const result = topupTracker.checkForCompletedTopup([])
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -66,7 +66,7 @@ describe('topupTracker', () => {
it('should return false if events array is empty', () => {
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
const result = checkForCompletedTopup([])
const result = topupTracker.checkForCompletedTopup([])
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -75,7 +75,7 @@ describe('topupTracker', () => {
it('should return false if events array is null', () => {
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
const result = checkForCompletedTopup(null)
const result = topupTracker.checkForCompletedTopup(null)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -94,7 +94,7 @@ describe('topupTracker', () => {
}
]
const result = checkForCompletedTopup(events)
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
@@ -122,7 +122,7 @@ describe('topupTracker', () => {
}
]
const result = checkForCompletedTopup(events)
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(true)
expect(mockTelemetry.trackApiCreditTopupSucceeded).toHaveBeenCalledOnce()
@@ -144,7 +144,7 @@ describe('topupTracker', () => {
}
]
const result = checkForCompletedTopup(events)
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -164,7 +164,7 @@ describe('topupTracker', () => {
}
]
const result = checkForCompletedTopup(events)
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -189,7 +189,7 @@ describe('topupTracker', () => {
}
]
const result = checkForCompletedTopup(events)
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -198,7 +198,7 @@ describe('topupTracker', () => {
describe('clearTopupTracking', () => {
it('should remove pending topup from localStorage', () => {
clearTopupTracking()
topupTracker.clearTopupTracking()
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'

View File

@@ -1,7 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTelemetry } from '@/platform/telemetry'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
@@ -11,14 +9,17 @@ describe('useTelemetry', () => {
vi.clearAllMocks()
})
it('should return null when not in cloud distribution', () => {
it('should return null when not in cloud distribution', async () => {
const { useTelemetry } = await import('@/platform/telemetry')
const provider = useTelemetry()
// Should return null for OSS builds
expect(provider).toBeNull()
})
}, 10000)
it('should return null consistently for OSS builds', async () => {
const { useTelemetry } = await import('@/platform/telemetry')
it('should return null consistently for OSS builds', () => {
const provider1 = useTelemetry()
const provider2 = useTelemetry()

View File

@@ -1,96 +1,19 @@
import { until } from '@vueuse/core'
import { setActivePinia } from 'pinia'
import { compare } from 'semver'
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 { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useReleaseService } from '@/platform/updates/common/releaseService'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
import { createTestingPinia } from '@pinia/testing'
import type { SystemStats } from '@/types'
// Mock the dependencies
vi.mock('semver', () => ({
compare: vi.fn(),
valid: vi.fn(() => '1.0.0')
}))
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn(() => true)
}))
vi.mock('semver')
vi.mock('@/utils/envUtil')
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
vi.mock('@/platform/updates/common/releaseService', () => {
const getReleases = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useReleaseService: () => ({
getReleases,
isLoading,
error
})
}
})
vi.mock('@/platform/settings/settingStore', () => {
const get = vi.fn((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
const set = vi.fn()
return {
useSettingStore: () => ({ get, set })
}
})
const mockSystemStatsState = vi.hoisted(() => ({
systemStats: {
system: {
comfyui_version: '1.0.0',
argv: []
}
} satisfies {
system: Partial<SystemStats['system']>
},
isInitialized: true,
reset() {
this.systemStats = {
system: {
comfyui_version: '1.0.0',
argv: []
} satisfies Partial<SystemStats['system']>
}
this.isInitialized = true
}
}))
vi.mock('@/stores/systemStatsStore', () => {
const refetchSystemStats = vi.fn()
const getFormFactor = vi.fn(() => 'git-windows')
return {
useSystemStatsStore: () => ({
get systemStats() {
return mockSystemStatsState.systemStats
},
set systemStats(val) {
mockSystemStatsState.systemStats = val
},
get isInitialized() {
return mockSystemStatsState.isInitialized
},
set isInitialized(val) {
mockSystemStatsState.isInitialized = val
},
refetchSystemStats,
getFormFactor
})
}
})
vi.mock('@/platform/updates/common/releaseService')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/systemStatsStore')
vi.mock('@vueuse/core', () => ({
until: vi.fn(() => Promise.resolve()),
useStorage: vi.fn(() => ({ value: {} })),
@@ -98,6 +21,27 @@ vi.mock('@vueuse/core', () => ({
}))
describe('useReleaseStore', () => {
let store: ReturnType<typeof useReleaseStore>
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,
project: 'comfyui' as const,
@@ -107,16 +51,71 @@ describe('useReleaseStore', () => {
attention: 'high' as const
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
beforeEach(async () => {
setActivePinia(createPinia())
vi.resetAllMocks()
mockSystemStatsState.reset()
// Reset all mocks
vi.clearAllMocks()
// Setup mock services with proper refs
mockReleaseService = {
getReleases: vi.fn(),
isLoading: ref(false),
error: ref(null)
}
mockSettingStore = {
get: vi.fn(),
set: vi.fn()
}
mockSystemStatsStore = {
systemStats: {
system: {
comfyui_version: '1.0.0'
}
},
isInitialized: true,
refetchSystemStats: vi.fn(),
getFormFactor: vi.fn(() => 'git-windows')
}
// Setup mock implementations
const { useReleaseService } =
await import('@/platform/updates/common/releaseService')
const { useSettingStore } = await import('@/platform/settings/settingStore')
const { useSystemStatsStore } = await import('@/stores/systemStatsStore')
const { isElectron } = await import('@/utils/envUtil')
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')
// Default showVersionUpdates to true
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
store = useReleaseStore()
})
describe('initial state', () => {
it('should initialize with default state', () => {
const store = useReleaseStore()
expect(store.releases).toEqual([])
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
@@ -125,7 +124,6 @@ describe('useReleaseStore', () => {
describe('computed properties', () => {
it('should return most recent release', () => {
const store = useReleaseStore()
const olderRelease = {
...mockRelease,
id: 2,
@@ -138,7 +136,6 @@ describe('useReleaseStore', () => {
})
it('should return 3 most recent releases', () => {
const store = useReleaseStore()
const releases = [
mockRelease,
{ ...mockRelease, id: 2, version: '1.1.0' },
@@ -151,7 +148,6 @@ describe('useReleaseStore', () => {
})
it('should show update button (shouldShowUpdateButton)', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1) // newer version available
store.releases = [mockRelease]
@@ -159,7 +155,6 @@ describe('useReleaseStore', () => {
})
it('should not show update button when no new version', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(-1) // current version is newer
store.releases = [mockRelease]
@@ -168,17 +163,19 @@ describe('useReleaseStore', () => {
})
describe('showVersionUpdates setting', () => {
beforeEach(async () => {
store.releases = [mockRelease]
})
describe('when notifications are enabled', () => {
beforeEach(() => {
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
beforeEach(async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
})
it('should show toast for medium/high attention releases', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
@@ -186,7 +183,6 @@ describe('useReleaseStore', () => {
})
it('should not show toast for low attention releases', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
const lowAttentionRelease = {
@@ -200,18 +196,13 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
@@ -219,13 +210,11 @@ describe('useReleaseStore', () => {
})
it('should fetch releases during initialization', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
@@ -235,35 +224,27 @@ describe('useReleaseStore', () => {
})
describe('when notifications are disabled', () => {
beforeEach(() => {
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
beforeEach(async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
})
it('should not show toast even with new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(false)
})
it('should not show red dot even with new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should not show popup even for latest version', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
@@ -271,19 +252,15 @@ describe('useReleaseStore', () => {
})
it('should skip fetching releases during initialization', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
await store.initialize()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
})
it('should not fetch releases when calling fetchReleases directly', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
await store.fetchReleases()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
})
@@ -291,13 +268,11 @@ describe('useReleaseStore', () => {
describe('release initialization', () => {
it('should fetch releases successfully', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
@@ -307,15 +282,12 @@ describe('useReleaseStore', () => {
})
it('should include form_factor in API call', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
vi.mocked(systemStatsStore.getFormFactor).mockReturnValue('desktop-mac')
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockSystemStatsStore.getFormFactor.mockReturnValue('desktop-mac')
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
@@ -324,22 +296,16 @@ describe('useReleaseStore', () => {
})
it('should skip fetching when --disable-api-nodes is present', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
mockSystemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
await store.initialize()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should skip fetching when --disable-api-nodes is one of multiple args', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
mockSystemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--disable-api-nodes',
@@ -348,46 +314,37 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should fetch normally when --disable-api-nodes is not present', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
mockSystemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--verbose'
]
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
expect(store.releases).toEqual([mockRelease])
})
it('should fetch normally when argv is undefined', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
// TODO: Consider deleting this test since the types have to be violated for it to be relevant
delete (systemStatsStore.systemStats!.system as { argv?: string[] }).argv
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockSystemStatsStore.systemStats!.system.argv = undefined
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
expect(store.releases).toEqual([mockRelease])
})
it('should handle API errors gracefully', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockResolvedValue(null)
releaseService.error.value = 'API Error'
mockReleaseService.getReleases.mockResolvedValue(null)
mockReleaseService.error.value = 'API Error'
await store.initialize()
@@ -396,9 +353,7 @@ describe('useReleaseStore', () => {
})
it('should handle non-Error objects', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockRejectedValue('String error')
mockReleaseService.getReleases.mockRejectedValue('String error')
await store.initialize()
@@ -406,14 +361,12 @@ describe('useReleaseStore', () => {
})
it('should set loading state correctly', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
let resolvePromise: (value: ReleaseNote[] | null) => void
const promise = new Promise<ReleaseNote[] | null>((resolve) => {
resolvePromise = resolve
})
vi.mocked(releaseService.getReleases).mockReturnValue(promise)
mockReleaseService.getReleases.mockReturnValue(promise)
const initPromise = store.initialize()
expect(store.isLoading).toBe(true)
@@ -425,23 +378,19 @@ describe('useReleaseStore', () => {
})
it('should fetch system stats if not available', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats = null
systemStatsStore.isInitialized = false
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(vi.mocked(until)).toHaveBeenCalled()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(until).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
it('should not set loading state when notifications disabled', async () => {
const store = useReleaseStore()
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
@@ -454,22 +403,16 @@ describe('useReleaseStore', () => {
describe('--disable-api-nodes argument handling', () => {
it('should skip fetchReleases when --disable-api-nodes is present', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
mockSystemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
await store.fetchReleases()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should skip fetchReleases when --disable-api-nodes is among other args', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
mockSystemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--disable-api-nodes',
@@ -478,109 +421,96 @@ describe('useReleaseStore', () => {
await store.fetchReleases()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should proceed with fetchReleases when --disable-api-nodes is not present', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
mockSystemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--verbose'
]
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
it('should proceed with fetchReleases when argv is undefined', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
delete (systemStatsStore.systemStats!.system as { argv?: string[] }).argv
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockSystemStatsStore.systemStats!.system.argv = undefined
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
it('should proceed with fetchReleases when system stats are not available', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats = null
systemStatsStore.isInitialized = false
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(until).toHaveBeenCalled()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
})
describe('action handlers', () => {
it('should handle skip release', async () => {
const store = useReleaseStore()
beforeEach(async () => {
store.releases = [mockRelease]
const settingStore = useSettingStore()
})
it('should handle skip release', async () => {
await store.handleSkipRelease('1.2.0')
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
'skipped'
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
it('should handle show changelog', async () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const settingStore = useSettingStore()
await store.handleShowChangelog('1.2.0')
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
'changelog seen'
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
it('should handle whats new seen', async () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const settingStore = useSettingStore()
await store.handleWhatsNewSeen('1.2.0')
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
"what's new seen"
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
@@ -589,9 +519,7 @@ describe('useReleaseStore', () => {
describe('popup visibility', () => {
it('should show toast for medium/high attention releases', () => {
const store = useReleaseStore()
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Release.Version') return null
if (key === 'Comfy.Release.Status') return null
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
@@ -606,10 +534,8 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', () => {
const store = useReleaseStore()
const settingStore = useSettingStore()
vi.mocked(compare).mockReturnValue(1)
vi.mocked(settingStore.get).mockImplementation((key: string) => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
@@ -620,11 +546,8 @@ describe('useReleaseStore', () => {
})
it('should show popup for latest version', () => {
const store = useReleaseStore()
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0' // Same as release
vi.mocked(settingStore.get).mockImplementation((key: string) => {
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
})
@@ -639,11 +562,8 @@ describe('useReleaseStore', () => {
describe('edge cases', () => {
it('should handle missing system stats gracefully', async () => {
const store = useReleaseStore()
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
systemStatsStore.systemStats = null
vi.mocked(settingStore.get).mockImplementation((key: string) => {
mockSystemStatsStore.systemStats = null
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
@@ -651,13 +571,11 @@ describe('useReleaseStore', () => {
await store.initialize()
// Should not fetch system stats when notifications disabled
expect(systemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
expect(mockSystemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
})
it('should handle concurrent fetchReleases calls', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockImplementation(
mockReleaseService.getReleases.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve([mockRelease]), 100)
@@ -671,37 +589,41 @@ describe('useReleaseStore', () => {
await Promise.all([promise1, promise2])
// Should only call API once due to loading check
expect(releaseService.getReleases).toHaveBeenCalledTimes(1)
expect(mockReleaseService.getReleases).toHaveBeenCalledTimes(1)
})
})
describe('isElectron environment checks', () => {
beforeEach(async () => {
// Set up a new version available
store.releases = [mockRelease]
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
})
describe('when running in Electron (desktop)', () => {
beforeEach(() => {
beforeEach(async () => {
const { isElectron } = await import('@/utils/envUtil')
vi.mocked(isElectron).mockReturnValue(true)
})
it('should show toast when conditions are met', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot when new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
@@ -710,12 +632,12 @@ describe('useReleaseStore', () => {
})
describe('when NOT running in Electron (web)', () => {
beforeEach(() => {
beforeEach(async () => {
const { isElectron } = await import('@/utils/envUtil')
vi.mocked(isElectron).mockReturnValue(false)
})
it('should NOT show toast even when all other conditions are met', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
// Set up all conditions that would normally show toast
@@ -725,15 +647,12 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even when new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show toast regardless of attention level', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
// Test with high attention releases
@@ -753,7 +672,6 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even with high attention release', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
store.releases = [{ ...mockRelease, attention: 'high' as const }]
@@ -762,10 +680,7 @@ describe('useReleaseStore', () => {
})
it('should NOT show popup even for latest version', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)

View File

@@ -1,4 +1,3 @@
import { until } from '@vueuse/core'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
@@ -358,15 +357,17 @@ describe('useVersionCompatibilityStore', () => {
describe('initialization', () => {
it('should fetch system stats if not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
await store.initialize()
expect(vi.mocked(until)).toHaveBeenCalled()
expect(until).toHaveBeenCalled()
})
it('should not fetch system stats if already available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
@@ -377,7 +378,7 @@ describe('useVersionCompatibilityStore', () => {
await store.initialize()
expect(vi.mocked(until)).not.toHaveBeenCalled()
expect(until).not.toHaveBeenCalled()
})
})
})

View File

@@ -3,9 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { createGraphThumbnail } from '@/renderer/core/thumbnail/graphThumbnailRenderer'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { api } from '@/scripts/api'
vi.mock('@/renderer/core/thumbnail/graphThumbnailRenderer', () => ({
createGraphThumbnail: vi.fn()
@@ -22,6 +19,12 @@ vi.mock('@/scripts/api', () => ({
}
}))
const { useWorkflowThumbnail } =
await import('@/renderer/core/thumbnail/useWorkflowThumbnail')
const { createGraphThumbnail } =
await import('@/renderer/core/thumbnail/graphThumbnailRenderer')
const { api } = await import('@/scripts/api')
describe('useWorkflowThumbnail', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>

View File

@@ -205,7 +205,7 @@ defineExpose({ runButtonClick })
<NodeWidgets
:node-data
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg max-w-100"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg max-w-100"
/>
</template>
</div>
@@ -237,7 +237,7 @@ defineExpose({ runButtonClick })
:node-data
:class="
cn(
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg',
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg',
nodeData.hasErrors &&
'ring-2 ring-inset ring-node-stroke-error'
)

View File

@@ -144,7 +144,7 @@ async function rerun(e: Event) {
{
icon: 'icon-[lucide--trash-2]',
label: t('queue.jobMenu.deleteAsset'),
action: () => mediaActions.deleteAssets(selectedItem!)
action: () => mediaActions.confirmDelete(selectedItem!)
}
]
]"

View File

@@ -1,58 +0,0 @@
<script setup lang="ts">
import {
CollapsibleRoot,
CollapsibleTrigger,
CollapsibleContent
} from 'reka-ui'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
</script>
<template>
<CollapsibleRoot class="flex flex-col">
<CollapsibleTrigger as-child>
<Button variant="secondary" class="size-10 self-end m-4 mb-2">
<i class="icon-[lucide--menu] size-8" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="flex gap-2 flex-col">
<div class="w-full border-b-2 border-border-subtle" />
<Popover>
<template #button>
<Button variant="secondary" size="lg" class="w-full">
<i class="icon-[comfy--workflow]" />
{{ t('Workflows') }}
</Button>
</template>
<WorkflowsSidebarTab class="h-300 w-[80vw]" />
</Popover>
<Button
variant="secondary"
size="lg"
class="w-full"
@click="useWorkflowTemplateSelectorDialog().show('menu')"
>
<i class="icon-[comfy--template]" />
{{ t('sideToolbar.templates') }}
</Button>
<Button
variant="secondary"
size="lg"
class="w-full"
@click="
useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source: 'button' }
})
"
>
<i class="icon-[lucide--log-out]" />
{{ t('linearMode.graphMode') }}
</Button>
<div class="w-full border-b-2 border-border-subtle" />
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -156,11 +156,7 @@ watch([selectedIndex, selectedOutput], doEmit)
watch(
() => outputs.media.value,
(newAssets, oldAssets) => {
if (
newAssets.length === oldAssets.length ||
(oldAssets.length === 0 && newAssets.length !== 1)
)
return
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
if (selectedIndex.value[0] <= 0) {
selectedIndex.value = [0, 0]
return

View File

@@ -200,8 +200,9 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
}))
}))
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { api } from '@/scripts/api'
const { useMinimap } =
await import('@/renderer/extensions/minimap/composables/useMinimap')
const { api } = await import('@/scripts/api')
describe('useMinimap', () => {
let moduleMockCanvasElement: HTMLCanvasElement

View File

@@ -103,7 +103,9 @@ describe('useMinimapRenderer', () => {
expect(vi.mocked(renderMinimapToCanvas)).not.toHaveBeenCalled()
})
it('should only render when redraw is needed', () => {
it('should only render when redraw is needed', async () => {
const { renderMinimapToCanvas } =
await import('@/renderer/extensions/minimap/minimapCanvasRenderer')
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })

View File

@@ -4,11 +4,6 @@ import { ref } from 'vue'
import type { Ref } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateMinimapScale,
calculateNodeBounds,
enforceMinimumBounds
} from '@/renderer/core/spatial/boundsCalculator'
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
@@ -71,7 +66,10 @@ describe('useMinimapViewport', () => {
expect(viewport.scale.value).toBe(1)
})
it('should calculate graph bounds from nodes', () => {
it('should calculate graph bounds from nodes', async () => {
const { calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 100,
minY: 100,
@@ -94,7 +92,10 @@ describe('useMinimapViewport', () => {
expect(enforceMinimumBounds).toHaveBeenCalled()
})
it('should handle empty graph', () => {
it('should handle empty graph', async () => {
const { calculateNodeBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
vi.mocked(calculateNodeBounds).mockReturnValue(null)
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
@@ -130,7 +131,11 @@ describe('useMinimapViewport', () => {
})
})
it('should calculate viewport transform', () => {
it('should calculate viewport transform', async () => {
const { calculateNodeBounds, enforceMinimumBounds, calculateMinimapScale } =
await import('@/renderer/core/spatial/boundsCalculator')
// Mock the bounds calculation
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 0,
minY: 0,
@@ -231,7 +236,10 @@ describe('useMinimapViewport', () => {
expect(() => viewport.centerViewOn(100, 100)).not.toThrow()
})
it('should calculate scale correctly', () => {
it('should calculate scale correctly', async () => {
const { calculateMinimapScale, calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
const testBounds = {
minX: 0,
minY: 0,

View File

@@ -108,11 +108,11 @@ import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import type { NodeBadgeProps } from './NodeBadge.vue'
@@ -160,12 +160,12 @@ const enterSubgraphTooltipConfig = computed(() => {
})
const resolveTitle = (info: VueNodeData | undefined) => {
const untitledLabel = st('g.untitled', 'Untitled')
return resolveNodeDisplayName(info ?? null, {
emptyLabel: untitledLabel,
untitledLabel,
st
})
const title = (info?.title ?? '').trim()
if (title.length > 0) return title
const nodeType = (info?.type ?? '').trim() || 'Untitled'
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
}
// Local state for title to provide immediate feedback

View File

@@ -1,7 +1,6 @@
<template>
<FloatLabel
variant="in"
:unstyled="hideLayoutField"
:class="
cn(
'rounded-lg space-y-1 focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
@@ -24,7 +23,7 @@
@pointerup.capture.stop
@contextmenu.capture.stop
/>
<label v-if="!hideLayoutField" :for="id">{{ displayName }}</label>
<label :for="id">{{ displayName }}</label>
</FloatLabel>
</template>
@@ -34,7 +33,6 @@ import Textarea from 'primevue/textarea'
import { computed, useId } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
import {
INPUT_EXCLUDED_PROPS,
@@ -50,8 +48,6 @@ const { widget, placeholder = '' } = defineProps<{
const modelValue = defineModel<string>({ default: '' })
const hideLayoutField = useHideLayoutField()
const filteredProps = computed(() =>
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)
)

View File

@@ -1,10 +1,6 @@
<template>
<WidgetLayoutField :widget>
<div
:class="
cn('flex w-fit items-center gap-2', !hideLayoutField && 'ml-auto')
"
>
<div class="ml-auto flex w-fit items-center gap-2">
<span
v-if="stateLabel"
:class="
@@ -33,7 +29,6 @@ import { computed } from 'vue'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
import {
STANDARD_EXCLUDED_PROPS,
@@ -48,8 +43,6 @@ const { widget } = defineProps<{
const modelValue = defineModel<boolean>()
const hideLayoutField = useHideLayoutField()
const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { inject } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
@@ -10,7 +11,7 @@ defineProps<{
>
}>()
const hideLayoutField = useHideLayoutField()
const hideLayoutField = inject<boolean>('hideLayoutField', false)
</script>
<template>

Some files were not shown because too many files have changed in this diff Show More