-
+
{{ $t('manager.conflicts.description') }}
{{ $t('manager.conflicts.info') }}
diff --git a/src/workbench/extensions/manager/components/manager/infoPanel/tabs/WarningTabPanel.vue b/src/workbench/extensions/manager/components/manager/infoPanel/tabs/WarningTabPanel.vue
index 853728074..f673318da 100644
--- a/src/workbench/extensions/manager/components/manager/infoPanel/tabs/WarningTabPanel.vue
+++ b/src/workbench/extensions/manager/components/manager/infoPanel/tabs/WarningTabPanel.vue
@@ -6,7 +6,7 @@
@click="showImportFailedDialog"
>
- {{
+ {{
t('serverStart.openLogs')
}}
diff --git a/tests-ui/fixtures/historyFixtures.ts b/tests-ui/fixtures/historyFixtures.ts
index 5ae3ff998..3a930a1ad 100644
--- a/tests-ui/fixtures/historyFixtures.ts
+++ b/tests-ui/fixtures/historyFixtures.ts
@@ -233,12 +233,17 @@ export const historyV2Fixture: HistoryResponseV2 = {
/**
* Expected V1 transformation of historyV2Fixture
+ * Priority is now synthetic based on execution_success timestamp:
+ * - complete-item-id: has timestamp → priority 1 (only one with timestamp)
+ * - no-status-id: no status → priority 0
+ * - no-meta-id: empty messages → priority 0
+ * - multi-output-id: empty messages → priority 0
*/
export const expectedV1Fixture: HistoryTaskItem[] = [
{
taskType: 'History',
prompt: [
- 24,
+ 1,
'complete-item-id',
{},
{
@@ -295,7 +300,7 @@ export const expectedV1Fixture: HistoryTaskItem[] = [
{
taskType: 'History',
prompt: [
- 23,
+ 0,
'no-status-id',
{},
{
@@ -319,7 +324,7 @@ export const expectedV1Fixture: HistoryTaskItem[] = [
{
taskType: 'History',
prompt: [
- 22,
+ 0,
'no-meta-id',
{},
{
@@ -342,7 +347,7 @@ export const expectedV1Fixture: HistoryTaskItem[] = [
{
taskType: 'History',
prompt: [
- 21,
+ 0,
'multi-output-id',
{},
{
diff --git a/tests-ui/fixtures/historySortingFixtures.ts b/tests-ui/fixtures/historySortingFixtures.ts
new file mode 100644
index 000000000..a7b630667
--- /dev/null
+++ b/tests-ui/fixtures/historySortingFixtures.ts
@@ -0,0 +1,258 @@
+/**
+ * @fileoverview Test fixtures for history V2 timestamp-based sorting
+ */
+import type { HistoryResponseV2 } from '@/platform/remote/comfyui/history/types/historyV2Types'
+
+export const historyV2WithMissingTimestamp: HistoryResponseV2 = {
+ history: [
+ {
+ prompt_id: 'item-timestamp-1000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-1000',
+ extra_data: {
+ client_id: 'test-client'
+ }
+ },
+ outputs: {
+ '1': {
+ images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-1000', timestamp: 1000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-timestamp-2000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-2000',
+ extra_data: {
+ client_id: 'test-client'
+ }
+ },
+ outputs: {
+ '2': {
+ images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-2000', timestamp: 2000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-no-timestamp',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-no-timestamp',
+ extra_data: {
+ client_id: 'test-client'
+ }
+ },
+ outputs: {
+ '3': {
+ images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ }
+ }
+ ]
+}
+
+export const historyV2FiveItemsSorting: HistoryResponseV2 = {
+ history: [
+ {
+ prompt_id: 'item-timestamp-3000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-3000',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '1': {
+ images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-3000', timestamp: 3000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-timestamp-1000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-1000',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '2': {
+ images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-1000', timestamp: 1000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-timestamp-5000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-5000',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '3': {
+ images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-5000', timestamp: 5000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-timestamp-2000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-2000',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '4': {
+ images: [{ filename: 'test4.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-2000', timestamp: 2000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-timestamp-4000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-4000',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '5': {
+ images: [{ filename: 'test5.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-4000', timestamp: 4000 }
+ ]
+ ]
+ }
+ }
+ ]
+}
+
+export const historyV2MultipleNoTimestamp: HistoryResponseV2 = {
+ history: [
+ {
+ prompt_id: 'item-no-timestamp-1',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-no-timestamp-1',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '1': {
+ images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ }
+ },
+ {
+ prompt_id: 'item-no-timestamp-2',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-no-timestamp-2',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '2': {
+ images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ }
+ },
+ {
+ prompt_id: 'item-no-timestamp-3',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-no-timestamp-3',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '3': {
+ images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ }
+ }
+ ]
+}
diff --git a/tests-ui/tests/composables/node/useCreditsBadge.test.ts b/tests-ui/tests/composables/node/useCreditsBadge.test.ts
new file mode 100644
index 000000000..72bb92338
--- /dev/null
+++ b/tests-ui/tests/composables/node/useCreditsBadge.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, vi } from 'vitest'
+
+import { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { LGraphBadge } from '@/lib/litegraph/src/LGraphBadge'
+import type { LGraphIcon } from '@/lib/litegraph/src/LGraphIcon'
+
+import { subgraphTest } from '../../litegraph/subgraph/fixtures/subgraphFixtures'
+
+import { usePriceBadge } from '@/composables/node/usePriceBadge'
+
+vi.mock('@/stores/workspace/colorPaletteStore', () => ({
+ useColorPaletteStore: () => ({
+ completedActivePalette: {
+ light_theme: false,
+ colors: { litegraph_base: {} }
+ }
+ })
+}))
+
+const { updateSubgraphCredits } = usePriceBadge()
+
+const mockNode = new LGraphNode('mock node')
+const mockIcon: Partial = { unicode: '\ue96b' }
+const badge: Partial = {
+ icon: mockIcon as LGraphIcon,
+ text: '$0.05/Run'
+}
+mockNode.badges = [badge as LGraphBadge]
+
+function getBadgeText(node: LGraphNode): string {
+ const badge = node.badges[0]
+ return (typeof badge === 'function' ? badge() : badge).text
+}
+
+describe('subgraph pricing', () => {
+ subgraphTest(
+ 'should not display badge for subgraphs without API nodes',
+ ({ subgraphWithNode }) => {
+ const { subgraphNode } = subgraphWithNode
+ updateSubgraphCredits(subgraphNode)
+ expect(subgraphNode.badges.length).toBe(0)
+ }
+ )
+ subgraphTest(
+ 'should return the price of a single contained API node',
+ ({ subgraphWithNode }) => {
+ const { subgraphNode, subgraph } = subgraphWithNode
+ subgraph.add(mockNode)
+ updateSubgraphCredits(subgraphNode)
+ expect(subgraphNode.badges.length).toBe(1)
+ expect(getBadgeText(subgraphNode)).toBe('$0.05/Run')
+ }
+ )
+ subgraphTest(
+ 'should return the number of api nodes if more than one exists',
+ ({ subgraphWithNode }) => {
+ const { subgraphNode, subgraph } = subgraphWithNode
+ for (let i = 0; i < 5; i++) subgraph.add(mockNode)
+ updateSubgraphCredits(subgraphNode)
+ expect(subgraphNode.badges.length).toBe(1)
+ expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
+ }
+ )
+})
diff --git a/tests-ui/tests/composables/useCoreCommands.test.ts b/tests-ui/tests/composables/useCoreCommands.test.ts
index f0b0bc8f7..743aadd93 100644
--- a/tests-ui/tests/composables/useCoreCommands.test.ts
+++ b/tests-ui/tests/composables/useCoreCommands.test.ts
@@ -85,6 +85,13 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({}))
}))
+vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
+ useSubscription: vi.fn(() => ({
+ isActiveSubscription: vi.fn().mockReturnValue(true),
+ showSubscriptionDialog: vi.fn()
+ }))
+}))
+
describe('useCoreCommands', () => {
const mockSubgraph = {
nodes: [
diff --git a/tests-ui/tests/composables/useLoad3d.test.ts b/tests-ui/tests/composables/useLoad3d.test.ts
new file mode 100644
index 000000000..5462922db
--- /dev/null
+++ b/tests-ui/tests/composables/useLoad3d.test.ts
@@ -0,0 +1,880 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, ref } from 'vue'
+
+import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
+import Load3d from '@/extensions/core/load3d/Load3d'
+import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
+import { useToastStore } from '@/platform/updates/common/toastStore'
+import { api } from '@/scripts/api'
+
+vi.mock('@/extensions/core/load3d/Load3d', () => ({
+ default: vi.fn()
+}))
+
+vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
+ default: {
+ splitFilePath: vi.fn(),
+ getResourceURL: vi.fn(),
+ uploadFile: vi.fn()
+ }
+}))
+
+vi.mock('@/platform/updates/common/toastStore', () => ({
+ useToastStore: vi.fn()
+}))
+
+vi.mock('@/scripts/api', () => ({
+ api: {
+ apiURL: vi.fn()
+ }
+}))
+
+vi.mock('@/i18n', () => ({
+ t: vi.fn((key) => key)
+}))
+
+describe('useLoad3d', () => {
+ let mockLoad3d: any
+ let mockNode: any
+ let mockToastStore: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ nodeToLoad3dMap.clear()
+
+ mockNode = {
+ properties: {
+ 'Scene Config': {
+ showGrid: true,
+ backgroundColor: '#000000',
+ backgroundImage: ''
+ },
+ 'Model Config': {
+ upDirection: 'original',
+ materialMode: 'original'
+ },
+ 'Camera Config': {
+ cameraType: 'perspective',
+ fov: 75,
+ state: null
+ },
+ 'Light Config': {
+ intensity: 5
+ },
+ 'Resource Folder': ''
+ },
+ widgets: [
+ { name: 'width', value: 512 },
+ { name: 'height', value: 512 }
+ ],
+ graph: {
+ setDirtyCanvas: vi.fn()
+ },
+ flags: {},
+ onMouseEnter: null,
+ onMouseLeave: null,
+ onResize: null,
+ onDrawBackground: null
+ }
+
+ mockLoad3d = {
+ toggleGrid: vi.fn(),
+ setBackgroundColor: vi.fn(),
+ setBackgroundImage: vi.fn().mockResolvedValue(undefined),
+ setUpDirection: vi.fn(),
+ setMaterialMode: vi.fn(),
+ toggleCamera: vi.fn(),
+ setFOV: vi.fn(),
+ setLightIntensity: vi.fn(),
+ setCameraState: vi.fn(),
+ loadModel: vi.fn().mockResolvedValue(undefined),
+ refreshViewport: vi.fn(),
+ updateStatusMouseOnNode: vi.fn(),
+ updateStatusMouseOnScene: vi.fn(),
+ handleResize: vi.fn(),
+ toggleAnimation: vi.fn(),
+ setAnimationSpeed: vi.fn(),
+ updateSelectedAnimation: vi.fn(),
+ startRecording: vi.fn().mockResolvedValue(undefined),
+ stopRecording: vi.fn(),
+ getRecordingDuration: vi.fn().mockReturnValue(10),
+ exportRecording: vi.fn(),
+ clearRecording: vi.fn(),
+ exportModel: vi.fn().mockResolvedValue(undefined),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ remove: vi.fn(),
+ renderer: {
+ domElement: {
+ hidden: false
+ }
+ }
+ }
+
+ vi.mocked(Load3d).mockImplementation(() => mockLoad3d)
+
+ mockToastStore = {
+ addAlert: vi.fn()
+ }
+ vi.mocked(useToastStore).mockReturnValue(mockToastStore)
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('initialization', () => {
+ it('should initialize with default values', () => {
+ const composable = useLoad3d(mockNode)
+
+ expect(composable.sceneConfig.value).toEqual({
+ showGrid: true,
+ backgroundColor: '#000000',
+ backgroundImage: ''
+ })
+ expect(composable.modelConfig.value).toEqual({
+ upDirection: 'original',
+ materialMode: 'original'
+ })
+ expect(composable.cameraConfig.value).toEqual({
+ cameraType: 'perspective',
+ fov: 75
+ })
+ expect(composable.lightConfig.value).toEqual({
+ intensity: 5
+ })
+ expect(composable.isRecording.value).toBe(false)
+ expect(composable.hasRecording.value).toBe(false)
+ expect(composable.loading.value).toBe(false)
+ })
+
+ it('should initialize Load3d with container and node', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ expect(Load3d).toHaveBeenCalledWith(containerRef, {
+ node: mockNode
+ })
+ expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
+ })
+
+ it('should restore configurations from node', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(true)
+ expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#000000')
+ expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('original')
+ expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('original')
+ expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('perspective')
+ expect(mockLoad3d.setFOV).toHaveBeenCalledWith(75)
+ expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
+ })
+
+ it('should set up node event handlers', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ expect(mockNode.onMouseEnter).toBeDefined()
+ expect(mockNode.onMouseLeave).toBeDefined()
+ expect(mockNode.onResize).toBeDefined()
+ expect(mockNode.onDrawBackground).toBeDefined()
+
+ // Test the handlers
+ mockNode.onMouseEnter()
+ expect(mockLoad3d.refreshViewport).toHaveBeenCalled()
+ expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(true)
+
+ mockNode.onMouseLeave()
+ expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(false)
+
+ mockNode.onResize()
+ expect(mockLoad3d.handleResize).toHaveBeenCalled()
+ })
+
+ it('should handle collapsed state', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ mockNode.flags.collapsed = true
+ mockNode.onDrawBackground()
+
+ expect(mockLoad3d.renderer.domElement.hidden).toBe(true)
+ })
+
+ it('should load model if model_file widget exists', async () => {
+ mockNode.widgets.push({ name: 'model_file', value: 'test.glb' })
+ vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
+ 'subfolder',
+ 'test.glb'
+ ])
+ vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
+ '/api/view/test.glb'
+ )
+ vi.mocked(api.apiURL).mockReturnValue(
+ 'http://localhost/api/view/test.glb'
+ )
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
+ 'http://localhost/api/view/test.glb'
+ )
+ })
+
+ it('should restore camera state after loading model', async () => {
+ mockNode.widgets.push({ name: 'model_file', value: 'test.glb' })
+ mockNode.properties['Camera Config'].state = {
+ position: { x: 1, y: 2, z: 3 },
+ target: { x: 0, y: 0, z: 0 }
+ }
+ vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
+ 'subfolder',
+ 'test.glb'
+ ])
+ vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
+ '/api/view/test.glb'
+ )
+ vi.mocked(api.apiURL).mockReturnValue(
+ 'http://localhost/api/view/test.glb'
+ )
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+ await nextTick()
+
+ expect(mockLoad3d.setCameraState).toHaveBeenCalledWith({
+ position: { x: 1, y: 2, z: 3 },
+ target: { x: 0, y: 0, z: 0 }
+ })
+ })
+
+ it('should set preview mode when no width/height widgets', async () => {
+ mockNode.widgets = []
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ expect(composable.isPreview.value).toBe(true)
+ })
+
+ it('should handle initialization errors', async () => {
+ vi.mocked(Load3d).mockImplementationOnce(() => {
+ throw new Error('Load3d creation failed')
+ })
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ expect(mockToastStore.addAlert).toHaveBeenCalledWith(
+ 'toastMessages.failedToInitializeLoad3d'
+ )
+ })
+
+ it('should handle missing container or node', async () => {
+ const composable = useLoad3d(mockNode)
+
+ await composable.initializeLoad3d(null as any)
+
+ expect(Load3d).not.toHaveBeenCalled()
+ })
+
+ it('should accept ref as parameter', () => {
+ const nodeRef = ref(mockNode)
+ const composable = useLoad3d(nodeRef)
+
+ expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
+ })
+ })
+
+ describe('waitForLoad3d', () => {
+ it('should execute callback immediately if Load3d exists', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ const callback = vi.fn()
+ composable.waitForLoad3d(callback)
+
+ expect(callback).toHaveBeenCalledWith(mockLoad3d)
+ })
+
+ it('should queue callback if Load3d does not exist', () => {
+ const composable = useLoad3d(mockNode)
+ const callback = vi.fn()
+
+ composable.waitForLoad3d(callback)
+
+ expect(callback).not.toHaveBeenCalled()
+ })
+
+ it('should execute queued callbacks after initialization', async () => {
+ const composable = useLoad3d(mockNode)
+ const callback1 = vi.fn()
+ const callback2 = vi.fn()
+
+ composable.waitForLoad3d(callback1)
+ composable.waitForLoad3d(callback2)
+
+ const containerRef = document.createElement('div')
+ await composable.initializeLoad3d(containerRef)
+
+ expect(callback1).toHaveBeenCalledWith(mockLoad3d)
+ expect(callback2).toHaveBeenCalledWith(mockLoad3d)
+ })
+ })
+
+ describe('configuration watchers', () => {
+ it('should update scene config when values change', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ mockLoad3d.toggleGrid.mockClear()
+ mockLoad3d.setBackgroundColor.mockClear()
+ mockLoad3d.setBackgroundImage.mockClear()
+
+ composable.sceneConfig.value = {
+ showGrid: false,
+ backgroundColor: '#ffffff',
+ backgroundImage: 'test.jpg'
+ }
+ await nextTick()
+
+ expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false)
+ expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ffffff')
+ expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('test.jpg')
+ expect(mockNode.properties['Scene Config']).toEqual({
+ showGrid: false,
+ backgroundColor: '#ffffff',
+ backgroundImage: 'test.jpg'
+ })
+ })
+
+ it('should update model config when values change', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.modelConfig.value.upDirection = '+y'
+ composable.modelConfig.value.materialMode = 'wireframe'
+ await nextTick()
+
+ expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
+ expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
+ expect(mockNode.properties['Model Config']).toEqual({
+ upDirection: '+y',
+ materialMode: 'wireframe'
+ })
+ })
+
+ it('should update camera config when values change', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.cameraConfig.value.cameraType = 'orthographic'
+ composable.cameraConfig.value.fov = 90
+ await nextTick()
+
+ expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
+ expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
+ expect(mockNode.properties['Camera Config']).toEqual({
+ cameraType: 'orthographic',
+ fov: 90,
+ state: null
+ })
+ })
+
+ it('should update light config when values change', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.lightConfig.value.intensity = 10
+ await nextTick()
+
+ expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
+ expect(mockNode.properties['Light Config']).toEqual({
+ intensity: 10
+ })
+ })
+ })
+
+ describe('animation controls', () => {
+ it('should toggle animation playback', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.playing.value = true
+ await nextTick()
+
+ expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(true)
+
+ composable.playing.value = false
+ await nextTick()
+
+ expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(false)
+ })
+
+ it('should update animation speed', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.selectedSpeed.value = 2
+ await nextTick()
+
+ expect(mockLoad3d.setAnimationSpeed).toHaveBeenCalledWith(2)
+ })
+
+ it('should update selected animation', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.selectedAnimation.value = 1
+ await nextTick()
+
+ expect(mockLoad3d.updateSelectedAnimation).toHaveBeenCalledWith(1)
+ })
+ })
+
+ describe('recording controls', () => {
+ it('should start recording', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ await composable.handleStartRecording()
+
+ expect(mockLoad3d.startRecording).toHaveBeenCalled()
+ expect(composable.isRecording.value).toBe(true)
+ })
+
+ it('should stop recording', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.handleStopRecording()
+
+ expect(mockLoad3d.stopRecording).toHaveBeenCalled()
+ expect(composable.isRecording.value).toBe(false)
+ expect(composable.recordingDuration.value).toBe(10)
+ expect(composable.hasRecording.value).toBe(true)
+ })
+
+ it('should export recording with timestamp', async () => {
+ const dateSpy = vi
+ .spyOn(Date.prototype, 'toISOString')
+ .mockReturnValue('2024-01-01T12:00:00.000Z')
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.handleExportRecording()
+
+ expect(mockLoad3d.exportRecording).toHaveBeenCalledWith(
+ '2024-01-01T12-00-00-000Z-scene-recording.mp4'
+ )
+
+ dateSpy.mockRestore()
+ })
+
+ it('should clear recording', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.hasRecording.value = true
+ composable.recordingDuration.value = 10
+
+ composable.handleClearRecording()
+
+ expect(mockLoad3d.clearRecording).toHaveBeenCalled()
+ expect(composable.hasRecording.value).toBe(false)
+ expect(composable.recordingDuration.value).toBe(0)
+ })
+ })
+
+ describe('background image handling', () => {
+ it('should upload and set background image', async () => {
+ vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
+ await composable.handleBackgroundImageUpdate(file)
+
+ expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
+ expect(composable.sceneConfig.value.backgroundImage).toBe(
+ 'uploaded-image.jpg'
+ )
+ expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith(
+ 'uploaded-image.jpg'
+ )
+ })
+
+ it('should use resource folder for upload', async () => {
+ mockNode.properties['Resource Folder'] = 'subfolder'
+ vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
+ await composable.handleBackgroundImageUpdate(file)
+
+ expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
+ })
+
+ it('should clear background image when file is null', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.sceneConfig.value.backgroundImage = 'existing.jpg'
+
+ await composable.handleBackgroundImageUpdate(null)
+
+ expect(composable.sceneConfig.value.backgroundImage).toBe('')
+ expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('')
+ })
+ })
+
+ describe('model export', () => {
+ it('should export model successfully', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ await composable.handleExportModel('glb')
+
+ expect(mockLoad3d.exportModel).toHaveBeenCalledWith('glb')
+ })
+
+ it('should show alert when no Load3d instance', async () => {
+ const composable = useLoad3d(mockNode)
+
+ await composable.handleExportModel('glb')
+
+ expect(mockToastStore.addAlert).toHaveBeenCalledWith(
+ 'toastMessages.no3dSceneToExport'
+ )
+ })
+
+ it('should handle export errors', async () => {
+ mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed'))
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ await composable.handleExportModel('glb')
+
+ expect(mockToastStore.addAlert).toHaveBeenCalledWith(
+ 'toastMessages.failedToExportModel'
+ )
+ })
+ })
+
+ describe('mouse interactions', () => {
+ it('should handle mouse enter on scene', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.handleMouseEnter()
+
+ expect(mockLoad3d.updateStatusMouseOnScene).toHaveBeenCalledWith(true)
+ })
+
+ it('should handle mouse leave on scene', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.handleMouseLeave()
+
+ expect(mockLoad3d.updateStatusMouseOnScene).toHaveBeenCalledWith(false)
+ })
+ })
+
+ describe('event handling', () => {
+ it('should add event listeners on initialization', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ const expectedEvents = [
+ 'materialModeChange',
+ 'backgroundColorChange',
+ 'lightIntensityChange',
+ 'fovChange',
+ 'cameraTypeChange',
+ 'showGridChange',
+ 'upDirectionChange',
+ 'backgroundImageChange',
+ 'backgroundImageLoadingStart',
+ 'backgroundImageLoadingEnd',
+ 'modelLoadingStart',
+ 'modelLoadingEnd',
+ 'exportLoadingStart',
+ 'exportLoadingEnd',
+ 'recordingStatusChange',
+ 'animationListChange'
+ ]
+
+ expectedEvents.forEach((event) => {
+ expect(mockLoad3d.addEventListener).toHaveBeenCalledWith(
+ event,
+ expect.any(Function)
+ )
+ })
+ })
+
+ it('should handle materialModeChange event', async () => {
+ let materialModeHandler: any
+
+ mockLoad3d.addEventListener.mockImplementation(
+ (event: string, handler: any) => {
+ if (event === 'materialModeChange') {
+ materialModeHandler = handler
+ }
+ }
+ )
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ materialModeHandler('wireframe')
+
+ expect(composable.modelConfig.value.materialMode).toBe('wireframe')
+ })
+
+ it('should handle loading events', async () => {
+ let modelLoadingStartHandler: any
+ let modelLoadingEndHandler: any
+
+ mockLoad3d.addEventListener.mockImplementation(
+ (event: string, handler: any) => {
+ if (event === 'modelLoadingStart') {
+ modelLoadingStartHandler = handler
+ } else if (event === 'modelLoadingEnd') {
+ modelLoadingEndHandler = handler
+ }
+ }
+ )
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ modelLoadingStartHandler()
+ expect(composable.loading.value).toBe(true)
+ expect(composable.loadingMessage.value).toBe('load3d.loadingModel')
+
+ modelLoadingEndHandler()
+ expect(composable.loading.value).toBe(false)
+ expect(composable.loadingMessage.value).toBe('')
+ })
+
+ it('should handle recordingStatusChange event', async () => {
+ let recordingStatusHandler: any
+
+ mockLoad3d.addEventListener.mockImplementation(
+ (event: string, handler: any) => {
+ if (event === 'recordingStatusChange') {
+ recordingStatusHandler = handler
+ }
+ }
+ )
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ recordingStatusHandler(false)
+
+ expect(composable.isRecording.value).toBe(false)
+ expect(composable.recordingDuration.value).toBe(10)
+ expect(composable.hasRecording.value).toBe(true)
+ })
+ })
+
+ describe('cleanup', () => {
+ it('should remove event listeners and clean up resources', async () => {
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ composable.cleanup()
+
+ expect(mockLoad3d.removeEventListener).toHaveBeenCalled()
+ expect(mockLoad3d.remove).toHaveBeenCalled()
+ expect(nodeToLoad3dMap.has(mockNode)).toBe(false)
+ })
+
+ it('should handle cleanup when not initialized', () => {
+ const composable = useLoad3d(mockNode)
+
+ expect(() => composable.cleanup()).not.toThrow()
+ })
+ })
+
+ describe('getModelUrl', () => {
+ it('should handle http URLs directly', async () => {
+ mockNode.widgets.push({
+ name: 'model_file',
+ value: 'http://example.com/model.glb'
+ })
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
+ 'http://example.com/model.glb'
+ )
+ })
+
+ it('should construct URL for local files', async () => {
+ mockNode.widgets.push({ name: 'model_file', value: 'models/test.glb' })
+ vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
+ 'models',
+ 'test.glb'
+ ])
+ vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
+ '/api/view/models/test.glb'
+ )
+ vi.mocked(api.apiURL).mockReturnValue(
+ 'http://localhost/api/view/models/test.glb'
+ )
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ expect(Load3dUtils.splitFilePath).toHaveBeenCalledWith('models/test.glb')
+ expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
+ 'models',
+ 'test.glb',
+ 'input'
+ )
+ expect(api.apiURL).toHaveBeenCalledWith('/api/view/models/test.glb')
+ expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
+ 'http://localhost/api/view/models/test.glb'
+ )
+ })
+
+ it('should use output type for preview mode', async () => {
+ mockNode.widgets = [{ name: 'model_file', value: 'test.glb' }] // No width/height widgets
+ vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb'])
+ vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
+ '/api/view/test.glb'
+ )
+ vi.mocked(api.apiURL).mockReturnValue(
+ 'http://localhost/api/view/test.glb'
+ )
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
+ '',
+ 'test.glb',
+ 'output'
+ )
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle null node ref', () => {
+ const nodeRef = ref(null)
+ const composable = useLoad3d(nodeRef)
+
+ const callback = vi.fn()
+ composable.waitForLoad3d(callback)
+
+ expect(callback).not.toHaveBeenCalled()
+ })
+
+ it('should handle missing configurations', async () => {
+ delete mockNode.properties['Scene Config']
+ delete mockNode.properties['Model Config']
+ delete mockNode.properties['Camera Config']
+ delete mockNode.properties['Light Config']
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ // Should not throw and should use defaults
+ expect(Load3d).toHaveBeenCalled()
+ })
+
+ it('should handle background image with existing config', async () => {
+ mockNode.properties['Scene Config'].backgroundImage = 'existing.jpg'
+
+ const composable = useLoad3d(mockNode)
+ const containerRef = document.createElement('div')
+
+ await composable.initializeLoad3d(containerRef)
+
+ expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
+ })
+ })
+})
diff --git a/tests-ui/tests/composables/useLoad3dDrag.test.ts b/tests-ui/tests/composables/useLoad3dDrag.test.ts
new file mode 100644
index 000000000..284d21c23
--- /dev/null
+++ b/tests-ui/tests/composables/useLoad3dDrag.test.ts
@@ -0,0 +1,267 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+
+import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
+import { useToastStore } from '@/platform/updates/common/toastStore'
+
+vi.mock('@/platform/updates/common/toastStore', () => ({
+ useToastStore: vi.fn()
+}))
+
+vi.mock('@/i18n', () => ({
+ t: vi.fn((key) => key)
+}))
+
+function createMockDragEvent(
+ type: string,
+ options: { hasFiles?: boolean; files?: File[] } = {}
+): DragEvent {
+ const files = options.files || []
+ const types = options.hasFiles ? ['Files'] : []
+
+ const dataTransfer = {
+ types,
+ files,
+ dropEffect: 'none' as DataTransfer['dropEffect']
+ }
+
+ const event = {
+ type,
+ dataTransfer
+ } as unknown as DragEvent
+
+ return event
+}
+
+describe('useLoad3dDrag', () => {
+ let mockToastStore: any
+ let mockOnModelDrop: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockToastStore = {
+ addAlert: vi.fn()
+ }
+ vi.mocked(useToastStore).mockReturnValue(mockToastStore)
+
+ mockOnModelDrop = vi.fn()
+ })
+
+ it('should initialize with default state', () => {
+ const { isDragging, dragMessage } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop
+ })
+
+ expect(isDragging.value).toBe(false)
+ expect(dragMessage.value).toBe('')
+ })
+
+ describe('handleDragOver', () => {
+ it('should set isDragging to true when files are being dragged', () => {
+ const { isDragging, handleDragOver } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop
+ })
+
+ const event = createMockDragEvent('dragover', { hasFiles: true })
+
+ handleDragOver(event)
+
+ expect(isDragging.value).toBe(true)
+ expect(event.dataTransfer!.dropEffect).toBe('copy')
+ })
+
+ it('should not set isDragging when disabled', () => {
+ const disabled = ref(true)
+ const { isDragging, handleDragOver } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop,
+ disabled
+ })
+
+ const event = createMockDragEvent('dragover', { hasFiles: true })
+
+ handleDragOver(event)
+
+ expect(isDragging.value).toBe(false)
+ })
+
+ it('should not set isDragging when no files are being dragged', () => {
+ const { isDragging, handleDragOver } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop
+ })
+
+ const event = createMockDragEvent('dragover', { hasFiles: false })
+
+ handleDragOver(event)
+
+ expect(isDragging.value).toBe(false)
+ })
+ })
+
+ describe('handleDragLeave', () => {
+ it('should reset isDragging to false', () => {
+ const { isDragging, handleDragLeave, handleDragOver } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop
+ })
+
+ // First set isDragging to true
+ const dragOverEvent = createMockDragEvent('dragover', { hasFiles: true })
+ handleDragOver(dragOverEvent)
+ expect(isDragging.value).toBe(true)
+
+ // Then test dragleave
+ handleDragLeave()
+ expect(isDragging.value).toBe(false)
+ })
+ })
+
+ describe('handleDrop', () => {
+ it('should call onModelDrop with valid model file', async () => {
+ const { handleDrop } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop
+ })
+
+ const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
+ const event = createMockDragEvent('drop', {
+ hasFiles: true,
+ files: [modelFile]
+ })
+
+ await handleDrop(event)
+
+ expect(mockOnModelDrop).toHaveBeenCalledWith(modelFile)
+ })
+
+ it('should show error toast for unsupported file types', async () => {
+ const { handleDrop } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop
+ })
+
+ const invalidFile = new File([], 'image.png', { type: 'image/png' })
+ const event = createMockDragEvent('drop', {
+ hasFiles: true,
+ files: [invalidFile]
+ })
+
+ await handleDrop(event)
+
+ expect(mockOnModelDrop).not.toHaveBeenCalled()
+ expect(mockToastStore.addAlert).toHaveBeenCalledWith(
+ 'load3d.unsupportedFileType'
+ )
+ })
+
+ it('should not call onModelDrop when disabled', async () => {
+ const disabled = ref(true)
+ const { handleDrop } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop,
+ disabled
+ })
+
+ const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
+ const event = createMockDragEvent('drop', {
+ hasFiles: true,
+ files: [modelFile]
+ })
+
+ await handleDrop(event)
+
+ expect(mockOnModelDrop).not.toHaveBeenCalled()
+ })
+
+ it('should reset isDragging after drop', async () => {
+ const { isDragging, handleDrop, handleDragOver } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop
+ })
+
+ // Set isDragging to true
+ const dragOverEvent = createMockDragEvent('dragover', { hasFiles: true })
+ handleDragOver(dragOverEvent)
+ expect(isDragging.value).toBe(true)
+
+ // Drop the file
+ const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
+ const dropEvent = createMockDragEvent('drop', {
+ hasFiles: true,
+ files: [modelFile]
+ })
+
+ await handleDrop(dropEvent)
+
+ expect(isDragging.value).toBe(false)
+ })
+
+ it('should support all valid 3D model extensions', async () => {
+ const { handleDrop } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop
+ })
+
+ const extensions = ['.gltf', '.glb', '.obj', '.fbx', '.stl']
+
+ for (const ext of extensions) {
+ mockOnModelDrop.mockClear()
+
+ const modelFile = new File([], `model${ext}`)
+ const event = createMockDragEvent('drop', {
+ hasFiles: true,
+ files: [modelFile]
+ })
+
+ await handleDrop(event)
+
+ expect(mockOnModelDrop).toHaveBeenCalledWith(modelFile)
+ }
+ })
+
+ it('should handle empty file list', async () => {
+ const { handleDrop } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop
+ })
+
+ const event = createMockDragEvent('drop', {
+ hasFiles: true,
+ files: []
+ })
+
+ await handleDrop(event)
+
+ expect(mockOnModelDrop).not.toHaveBeenCalled()
+ expect(mockToastStore.addAlert).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('disabled option', () => {
+ it('should work with reactive disabled ref', () => {
+ const disabled = ref(false)
+ const { isDragging, handleDragOver } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop,
+ disabled
+ })
+
+ const event = createMockDragEvent('dragover', { hasFiles: true })
+
+ // Should work when disabled is false
+ handleDragOver(event)
+ expect(isDragging.value).toBe(true)
+
+ // Reset
+ isDragging.value = false
+
+ // Should not work when disabled is true
+ disabled.value = true
+ handleDragOver(event)
+ expect(isDragging.value).toBe(false)
+ })
+
+ it('should work with plain boolean', () => {
+ const { isDragging, handleDragOver } = useLoad3dDrag({
+ onModelDrop: mockOnModelDrop,
+ disabled: false
+ })
+
+ const event = createMockDragEvent('dragover', { hasFiles: true })
+ handleDragOver(event)
+ expect(isDragging.value).toBe(true)
+ })
+ })
+})
diff --git a/tests-ui/tests/composables/useLoad3dViewer.test.ts b/tests-ui/tests/composables/useLoad3dViewer.test.ts
index a607186e5..589297977 100644
--- a/tests-ui/tests/composables/useLoad3dViewer.test.ts
+++ b/tests-ui/tests/composables/useLoad3dViewer.test.ts
@@ -41,20 +41,28 @@ describe('useLoad3dViewer', () => {
mockNode = {
properties: {
- 'Background Color': '#282828',
- 'Show Grid': true,
- 'Camera Type': 'perspective',
- FOV: 75,
- 'Light Intensity': 1,
- 'Camera Info': null,
- 'Background Image': '',
- 'Up Direction': 'original',
- 'Material Mode': 'original',
- 'Edge Threshold': 85
+ 'Scene Config': {
+ backgroundColor: '#282828',
+ showGrid: true,
+ backgroundImage: ''
+ },
+ 'Camera Config': {
+ cameraType: 'perspective',
+ fov: 75
+ },
+ 'Light Config': {
+ intensity: 1
+ },
+ 'Model Config': {
+ upDirection: 'original',
+ materialMode: 'original'
+ },
+ 'Resource Folder': ''
},
graph: {
setDirtyCanvas: vi.fn()
- }
+ },
+ widgets: []
} as any
mockLoad3d = {
@@ -66,7 +74,6 @@ describe('useLoad3dViewer', () => {
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
- setEdgeThreshold: vi.fn(),
exportModel: vi.fn().mockResolvedValue(undefined),
handleResize: vi.fn(),
updateStatusMouseOnViewer: vi.fn(),
@@ -77,7 +84,8 @@ describe('useLoad3dViewer', () => {
cameraType: 'perspective'
}),
forceRender: vi.fn(),
- remove: vi.fn()
+ remove: vi.fn(),
+ setTargetSize: vi.fn()
}
mockSourceLoad3d = {
@@ -142,7 +150,6 @@ describe('useLoad3dViewer', () => {
expect(viewer.hasBackgroundImage.value).toBe(false)
expect(viewer.upDirection.value).toBe('original')
expect(viewer.materialMode.value).toBe('original')
- expect(viewer.edgeThreshold.value).toBe(85)
})
it('should initialize viewer with source Load3d state', async () => {
@@ -169,7 +176,6 @@ describe('useLoad3dViewer', () => {
expect(viewer.fov.value).toBe(75)
expect(viewer.upDirection.value).toBe('original')
expect(viewer.materialMode.value).toBe('original')
- expect(viewer.edgeThreshold.value).toBe(85)
})
it('should handle background image during initialization', async () => {
@@ -177,7 +183,7 @@ describe('useLoad3dViewer', () => {
type: 'image',
value: ''
})
- mockNode.properties['Background Image'] = 'test-image.jpg'
+ mockNode.properties['Scene Config'].backgroundImage = 'test-image.jpg'
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -302,18 +308,6 @@ describe('useLoad3dViewer', () => {
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
})
- it('should update edge threshold when state changes', async () => {
- const viewer = useLoad3dViewer(mockNode)
- const containerRef = document.createElement('div')
-
- await viewer.initializeViewer(containerRef, mockSourceLoad3d)
-
- viewer.edgeThreshold.value = 90
- await nextTick()
-
- expect(mockLoad3d.setEdgeThreshold).toHaveBeenCalledWith(90)
- })
-
it('should handle watcher errors gracefully', async () => {
mockLoad3d.setBackgroundColor.mockImplementationOnce(() => {
throw new Error('Color update failed')
@@ -411,16 +405,20 @@ describe('useLoad3dViewer', () => {
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
- mockNode.properties['Background Color'] = '#ff0000'
- mockNode.properties['Show Grid'] = false
+ mockNode.properties['Scene Config'].backgroundColor = '#ff0000'
+ mockNode.properties['Scene Config'].showGrid = false
viewer.restoreInitialState()
- expect(mockNode.properties['Background Color']).toBe('#282828')
- expect(mockNode.properties['Show Grid']).toBe(true)
- expect(mockNode.properties['Camera Type']).toBe('perspective')
- expect(mockNode.properties['FOV']).toBe(75)
- expect(mockNode.properties['Light Intensity']).toBe(1)
+ expect(mockNode.properties['Scene Config'].backgroundColor).toBe(
+ '#282828'
+ )
+ expect(mockNode.properties['Scene Config'].showGrid).toBe(true)
+ expect(mockNode.properties['Camera Config'].cameraType).toBe(
+ 'perspective'
+ )
+ expect(mockNode.properties['Camera Config'].fov).toBe(75)
+ expect(mockNode.properties['Light Config'].intensity).toBe(1)
})
})
@@ -437,8 +435,10 @@ describe('useLoad3dViewer', () => {
const result = await viewer.applyChanges()
expect(result).toBe(true)
- expect(mockNode.properties['Background Color']).toBe('#ff0000')
- expect(mockNode.properties['Show Grid']).toBe(false)
+ expect(mockNode.properties['Scene Config'].backgroundColor).toBe(
+ '#ff0000'
+ )
+ expect(mockNode.properties['Scene Config'].showGrid).toBe(false)
expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith(
mockLoad3d,
mockSourceLoad3d
@@ -582,7 +582,10 @@ describe('useLoad3dViewer', () => {
it('should handle orthographic camera', async () => {
mockSourceLoad3d.getCurrentCameraType.mockReturnValue('orthographic')
- mockSourceLoad3d.cameraManager = {} // No perspective camera
+ mockSourceLoad3d.cameraManager = {
+ perspectiveCamera: { fov: 75 }
+ }
+ delete mockNode.properties['Camera Config'].cameraType
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
diff --git a/tests-ui/tests/composables/useTemplateFiltering.test.ts b/tests-ui/tests/composables/useTemplateFiltering.test.ts
index 8fbdf59fd..b6428704b 100644
--- a/tests-ui/tests/composables/useTemplateFiltering.test.ts
+++ b/tests-ui/tests/composables/useTemplateFiltering.test.ts
@@ -74,7 +74,8 @@ describe('useTemplateFiltering', () => {
tags: ['API', 'Video'],
models: ['Flux'],
date: '2024-06-01',
- vram: 15 * 1024 ** 3
+ vram: 15 * 1024 ** 3,
+ openSource: false
},
{
name: 'portrait-flow',
@@ -101,11 +102,11 @@ describe('useTemplateFiltering', () => {
searchQuery,
selectedModels,
selectedUseCases,
- selectedLicenses,
+ selectedRunsOn,
filteredTemplates,
availableModels,
availableUseCases,
- availableLicenses,
+ availableRunsOn,
filteredCount,
totalCount,
removeUseCaseFilter,
@@ -120,10 +121,7 @@ describe('useTemplateFiltering', () => {
'Portrait',
'Video'
])
- expect(availableLicenses.value).toEqual([
- 'Open Source',
- 'Closed Source (API Nodes)'
- ])
+ expect(availableRunsOn.value).toEqual(['ComfyUI', 'External or Remote API'])
searchQuery.value = 'enterprise'
await nextTick()
@@ -133,7 +131,7 @@ describe('useTemplateFiltering', () => {
'api-template'
])
- selectedLicenses.value = ['Closed Source (API Nodes)']
+ selectedRunsOn.value = ['External or Remote API']
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template'
diff --git a/tests-ui/tests/extensions/contextMenuExtension.test.ts b/tests-ui/tests/extensions/contextMenuExtension.test.ts
index e6766b0da..5cd9d3664 100644
--- a/tests-ui/tests/extensions/contextMenuExtension.test.ts
+++ b/tests-ui/tests/extensions/contextMenuExtension.test.ts
@@ -77,9 +77,9 @@ describe('Context Menu Extension API', () => {
extensionStore.registerExtension(ext1)
extensionStore.registerExtension(ext2)
- const items = extensionService
+ const items: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
- .flat() as IContextMenuValue[]
+ .flat()
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ content: 'Canvas Item 1' })
@@ -105,9 +105,9 @@ describe('Context Menu Extension API', () => {
extensionStore.registerExtension(extension)
- const items = extensionService
+ const items: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
- .flat() as IContextMenuValue[]
+ .flat()
expect(items).toHaveLength(3)
expect(items[0].content).toBe('Menu with Submenu')
@@ -127,13 +127,44 @@ describe('Context Menu Extension API', () => {
extensionStore.registerExtension(canvasExtension)
extensionStore.registerExtension(extensionWithoutCanvasMenu)
- const items = extensionService
+ const items: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
- .flat() as IContextMenuValue[]
+ .flat()
expect(items).toHaveLength(1)
expect(items[0].content).toBe('Canvas Item 1')
})
+
+ it('should not duplicate menu items when collected multiple times', () => {
+ const extension = createCanvasMenuExtension('Test Extension', [
+ canvasMenuItem1,
+ canvasMenuItem2
+ ])
+
+ extensionStore.registerExtension(extension)
+
+ // Collect items multiple times (simulating repeated menu opens)
+ const items1: IContextMenuValue[] = extensionService
+ .invokeExtensions('getCanvasMenuItems', mockCanvas)
+ .flat()
+
+ const items2: IContextMenuValue[] = extensionService
+ .invokeExtensions('getCanvasMenuItems', mockCanvas)
+ .flat()
+
+ // Both collections should have the same items (no duplication)
+ expect(items1).toHaveLength(2)
+ expect(items2).toHaveLength(2)
+
+ // Verify items are unique by checking their content
+ const contents1 = items1.map((item) => item.content)
+ const uniqueContents1 = new Set(contents1)
+ expect(uniqueContents1.size).toBe(contents1.length)
+
+ const contents2 = items2.map((item) => item.content)
+ const uniqueContents2 = new Set(contents2)
+ expect(uniqueContents2.size).toBe(contents2.length)
+ })
})
describe('collectNodeMenuItems', () => {
@@ -147,9 +178,9 @@ describe('Context Menu Extension API', () => {
extensionStore.registerExtension(ext1)
extensionStore.registerExtension(ext2)
- const items = extensionService
+ const items: IContextMenuValue[] = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
- .flat() as IContextMenuValue[]
+ .flat()
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ content: 'Node Item 1' })
@@ -172,9 +203,9 @@ describe('Context Menu Extension API', () => {
extensionStore.registerExtension(extension)
- const items = extensionService
+ const items: IContextMenuValue[] = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
- .flat() as IContextMenuValue[]
+ .flat()
expect(items[0].content).toBe('Node Menu with Submenu')
expect(items[0].submenu?.options).toHaveLength(2)
@@ -189,9 +220,9 @@ describe('Context Menu Extension API', () => {
extensionStore.registerExtension(nodeExtension)
extensionStore.registerExtension(extensionWithoutNodeMenu)
- const items = extensionService
+ const items: IContextMenuValue[] = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
- .flat() as IContextMenuValue[]
+ .flat()
expect(items).toHaveLength(1)
expect(items[0].content).toBe('Node Item 1')
diff --git a/tests-ui/tests/extensions/contextMenuExtensionName.test.ts b/tests-ui/tests/extensions/contextMenuExtensionName.test.ts
new file mode 100644
index 000000000..1fb0b5fd5
--- /dev/null
+++ b/tests-ui/tests/extensions/contextMenuExtensionName.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
+import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
+
+/**
+ * Test that demonstrates the extension name appearing in deprecation warnings
+ */
+describe('Context Menu Extension Name in Warnings', () => {
+ it('should include extension name in deprecation warning', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ // Install compatibility layer
+ legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
+
+ // Simulate what happens during extension setup
+ legacyMenuCompat.setCurrentExtension('MyCustomExtension')
+
+ // Extension monkey-patches the method
+ const original = LGraphCanvas.prototype.getCanvasMenuOptions
+ LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
+ const items = (original as any).apply(this, args)
+ items.push({ content: 'My Custom Menu Item', callback: () => {} })
+ return items
+ }
+
+ // Clear extension (happens after setup completes)
+ legacyMenuCompat.setCurrentExtension(null)
+
+ // Verify the warning includes the extension name
+ expect(warnSpy).toHaveBeenCalled()
+ const warningMessage = warnSpy.mock.calls[0][0]
+
+ expect(warningMessage).toContain('[DEPRECATED]')
+ expect(warningMessage).toContain('getCanvasMenuOptions')
+ expect(warningMessage).toContain('"MyCustomExtension"')
+
+ vi.restoreAllMocks()
+ })
+
+ it('should include extension name for node menu patches', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ // Install compatibility layer
+ legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions')
+
+ // Simulate what happens during extension setup
+ legacyMenuCompat.setCurrentExtension('AnotherExtension')
+
+ // Extension monkey-patches the method
+ const original = LGraphCanvas.prototype.getNodeMenuOptions
+ LGraphCanvas.prototype.getNodeMenuOptions = function (...args: any[]) {
+ const items = (original as any).apply(this, args)
+ items.push({ content: 'My Node Menu Item', callback: () => {} })
+ return items
+ }
+
+ // Clear extension (happens after setup completes)
+ legacyMenuCompat.setCurrentExtension(null)
+
+ // Verify the warning includes extension info
+ expect(warnSpy).toHaveBeenCalled()
+ const warningMessage = warnSpy.mock.calls[0][0]
+
+ expect(warningMessage).toContain('[DEPRECATED]')
+ expect(warningMessage).toContain('getNodeMenuOptions')
+ expect(warningMessage).toContain('"AnotherExtension"')
+
+ vi.restoreAllMocks()
+ })
+})
diff --git a/tests-ui/tests/lib/litegraph/src/widgets/ComboWidget.test.ts b/tests-ui/tests/lib/litegraph/src/widgets/ComboWidget.test.ts
new file mode 100644
index 000000000..e3b9ef6cd
--- /dev/null
+++ b/tests-ui/tests/lib/litegraph/src/widgets/ComboWidget.test.ts
@@ -0,0 +1,1088 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
+import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
+import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
+import { ComboWidget } from '@/lib/litegraph/src/widgets/ComboWidget'
+
+const { LGraphCanvas } = await vi.importActual<
+ typeof import('@/lib/litegraph/src/LGraphCanvas')
+>('@/lib/litegraph/src/LGraphCanvas')
+type LGraphCanvasType = InstanceType
+
+type ContextMenuInstance = {
+ addItem?: (
+ name: string,
+ value: string,
+ options: { callback?: (value: string) => void; className?: string }
+ ) => void
+}
+
+interface MockWidgetConfig extends Omit {
+ options: IComboWidget['options']
+}
+
+function createMockWidgetConfig(
+ overrides: Partial = {}
+): MockWidgetConfig {
+ return {
+ type: 'combo',
+ name: 'test',
+ value: '',
+ options: { values: [] },
+ y: 0,
+ ...overrides
+ }
+}
+
+function setupIncrementDecrementTest() {
+ const mockCanvas = {
+ ds: { scale: 1 },
+ last_mouseclick: 1
+ } as LGraphCanvasType
+ const mockEvent = {} as CanvasPointerEvent
+ return { mockCanvas, mockEvent }
+}
+
+describe('ComboWidget', () => {
+ let node: LGraphNode
+ let widget: ComboWidget
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ node = new LGraphNode('TestNode')
+ })
+
+ describe('_displayValue', () => {
+ it('should return value as-is for array values', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'fast',
+ options: { values: ['fast', 'slow', 'medium'] }
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe('fast')
+ })
+
+ it('should return mapped value for object values', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'quality',
+ value: 'hq',
+ options: {
+ values: {
+ hq: 'High Quality',
+ mq: 'Medium Quality',
+ lq: 'Low Quality'
+ }
+ }
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe('High Quality')
+ })
+
+ it('should return empty string when disabled', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'fast',
+ options: { values: ['fast', 'slow'] },
+ computedDisabled: true
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe('')
+ })
+
+ it('should convert number values to string before display', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'index',
+ value: 42,
+ options: { values: ['0', '1', '42'] }
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe('42')
+ })
+ })
+
+ describe('canIncrement / canDecrement', () => {
+ it('should return true when not at end/start of list', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'medium',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ expect(widget.canIncrement()).toBe(true)
+ expect(widget.canDecrement()).toBe(true)
+ })
+
+ it('should return false from canDecrement when at first value', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'fast',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ expect(widget.canDecrement()).toBe(false)
+ expect(widget.canIncrement()).toBe(true)
+ })
+
+ it('should return false from canIncrement when at last value', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'slow',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ expect(widget.canIncrement()).toBe(false)
+ expect(widget.canDecrement()).toBe(true)
+ })
+
+ it('should return false when list has only one item', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'only',
+ options: { values: ['only'] }
+ }),
+ node
+ )
+
+ expect(widget.canIncrement()).toBe(false)
+ expect(widget.canDecrement()).toBe(false)
+ })
+
+ it('should allow increment/decrement when duplicate values exist at different indices', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'duplicate',
+ options: { values: ['duplicate', 'other', 'duplicate'] }
+ }),
+ node
+ )
+
+ expect(widget.canIncrement()).toBe(true)
+ expect(widget.canDecrement()).toBe(true)
+ })
+
+ it('should return false for function values (DEPRECATED - legacy duck-typed behavior)', () => {
+ const valuesFn = vi.fn().mockReturnValue(['a', 'b', 'c'])
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'b',
+ options: { values: valuesFn }
+ }),
+ node
+ )
+
+ // Function values are legacy - should be permissive (return false)
+ expect(widget.canIncrement()).toBe(false)
+ expect(widget.canDecrement()).toBe(false)
+ })
+ })
+
+ describe('incrementValue / decrementValue', () => {
+ it('should increment value to next in list', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'fast',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
+
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+ widget.incrementValue({ e: mockEvent, node, canvas: mockCanvas })
+
+ expect(setValueSpy).toHaveBeenCalledWith('medium', {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ expect(mockCanvas.last_mouseclick).toBe(0) // Avoid double click event
+ })
+
+ it('should decrement value to previous in list', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'medium',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
+
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+ widget.decrementValue({ e: mockEvent, node, canvas: mockCanvas })
+
+ expect(setValueSpy).toHaveBeenCalledWith('fast', {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ expect(mockCanvas.last_mouseclick).toBe(0)
+ })
+
+ it('should clamp at last value when incrementing beyond', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'slow',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
+
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+ widget.incrementValue({ e: mockEvent, node, canvas: mockCanvas })
+
+ // Should stay at 'slow' (last value)
+ expect(setValueSpy).toHaveBeenCalledWith('slow', {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+
+ it('should clamp at first value when decrementing beyond', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'fast',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
+
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+ widget.decrementValue({ e: mockEvent, node, canvas: mockCanvas })
+
+ // Should stay at 'fast' (first value)
+ expect(setValueSpy).toHaveBeenCalledWith('fast', {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+
+ it('should set value to index position when incrementing object-type values', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'quality',
+ value: 'hq',
+ options: {
+ values: {
+ hq: 'High Quality',
+ mq: 'Medium Quality'
+ }
+ }
+ }),
+ node
+ )
+
+ const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
+
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+ widget.incrementValue({ e: mockEvent, node, canvas: mockCanvas })
+
+ // For object values, setValue receives the index
+ expect(setValueSpy).toHaveBeenCalledWith(1, {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+ })
+
+ describe('onClick', () => {
+ it('should decrement value when left arrow clicked', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'medium',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ const mockCanvas = {
+ ds: { scale: 1 },
+ last_mouseclick: 0
+ } as LGraphCanvasType
+ const mockEvent = { canvasX: 60 } as CanvasPointerEvent // 60 - 50 = 10 < 40 (left arrow)
+ node.pos = [50, 50]
+
+ const decrementSpy = vi.spyOn(widget, 'decrementValue')
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ expect(decrementSpy).toHaveBeenCalledWith({
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+
+ it('should increment value when right arrow clicked', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'medium',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ const mockCanvas = {
+ ds: { scale: 1 },
+ last_mouseclick: 0
+ } as LGraphCanvasType
+ const mockEvent = { canvasX: 240 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ const incrementSpy = vi.spyOn(widget, 'incrementValue')
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ expect(incrementSpy).toHaveBeenCalledWith({
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+
+ it('should show dropdown menu when clicking center area with array values', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'medium',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ const mockContextMenu = vi.fn()
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ expect(mockContextMenu).toHaveBeenCalledWith(
+ ['fast', 'medium', 'slow'],
+ expect.objectContaining({
+ scale: 1,
+ event: mockEvent,
+ className: 'dark'
+ })
+ )
+ })
+
+ it('should show dropdown menu with object display values', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'quality',
+ value: 'mq',
+ options: {
+ values: {
+ hq: 'High Quality',
+ mq: 'Medium Quality',
+ lq: 'Low Quality'
+ }
+ }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ const mockContextMenu = vi.fn()
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ // Should show the display values (values), not keys
+ expect(mockContextMenu).toHaveBeenCalledWith(
+ ['High Quality', 'Medium Quality', 'Low Quality'],
+ expect.objectContaining({
+ scale: 1,
+ event: mockEvent,
+ className: 'dark'
+ })
+ )
+ })
+
+ it('should set value when selecting from dropdown with array values', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'fast',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ let capturedCallback: ((value: string) => void) | undefined
+ const mockContextMenu = vi.fn((_values, options) => {
+ capturedCallback = options.callback
+ return {} as ContextMenuInstance
+ })
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ // Simulate selecting 'slow' from dropdown
+ capturedCallback?.('slow')
+
+ expect(setValueSpy).toHaveBeenCalledWith('slow', {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+
+ it('should set value to selected index for object-type values', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'quality',
+ value: 'hq',
+ options: {
+ values: {
+ hq: 'High Quality',
+ mq: 'Medium Quality',
+ lq: 'Low Quality'
+ }
+ }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ let capturedCallback: ((value: string) => void) | undefined
+ const mockContextMenu = vi.fn((_values, options) => {
+ capturedCallback = options.callback
+ return {} as ContextMenuInstance
+ })
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ // Simulate selecting 'Medium Quality' (index 1) from dropdown
+ capturedCallback?.('Medium Quality')
+
+ expect(setValueSpy).toHaveBeenCalledWith(1, {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+
+ it('should prevent menu scaling below 100%', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'fast',
+ options: { values: ['fast', 'slow'] }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 0.5 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ const mockContextMenu = vi.fn()
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ expect(mockContextMenu).toHaveBeenCalledWith(
+ expect.any(Array),
+ expect.objectContaining({
+ scale: 1 // Math.max(1, 0.5) = 1
+ })
+ )
+ })
+
+ it('should warn when using deprecated function values', () => {
+ const deprecationCallback = vi.fn()
+ const originalCallbacks = LiteGraph.onDeprecationWarning
+ LiteGraph.onDeprecationWarning = [deprecationCallback]
+
+ const valuesFn = vi.fn().mockReturnValue(['a', 'b', 'c'])
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'a',
+ options: { values: valuesFn }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ const mockContextMenu = vi.fn()
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ expect(deprecationCallback).toHaveBeenCalledWith(
+ 'Using a function for values is deprecated. Use an array of unique values instead.',
+ undefined
+ )
+
+ LiteGraph.onDeprecationWarning = originalCallbacks
+ })
+ })
+
+ describe('with getOptionLabel', () => {
+ const HASH_FILENAME =
+ '72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
+ const HASH_FILENAME_2 =
+ 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
+
+ describe('_displayValue', () => {
+ it('should return formatted value when getOptionLabel provided', () => {
+ const mockGetOptionLabel = vi
+ .fn()
+ .mockReturnValue('Beautiful Sunset.png')
+
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'image',
+ value: HASH_FILENAME,
+ options: {
+ values: [HASH_FILENAME],
+ getOptionLabel: mockGetOptionLabel
+ }
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe('Beautiful Sunset.png')
+ expect(mockGetOptionLabel).toHaveBeenCalledWith(HASH_FILENAME)
+ })
+
+ it('should return original value when getOptionLabel not provided', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'image',
+ value: HASH_FILENAME,
+ options: { values: [HASH_FILENAME] }
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe(HASH_FILENAME)
+ })
+
+ it('should not call getOptionLabel when disabled', () => {
+ const mockGetOptionLabel = vi.fn()
+
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'image',
+ value: HASH_FILENAME,
+ options: {
+ values: [HASH_FILENAME],
+ getOptionLabel: mockGetOptionLabel
+ },
+ computedDisabled: true
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe('')
+ expect(mockGetOptionLabel).not.toHaveBeenCalled()
+ })
+
+ it('should handle getOptionLabel error gracefully', () => {
+ const mockGetOptionLabel = vi.fn().mockImplementation(() => {
+ throw new Error('Formatting failed')
+ })
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {})
+
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'image',
+ value: HASH_FILENAME,
+ options: {
+ values: [HASH_FILENAME],
+ getOptionLabel: mockGetOptionLabel
+ }
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe(HASH_FILENAME)
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to map value:',
+ expect.any(Error)
+ )
+
+ consoleErrorSpy.mockRestore()
+ })
+
+ it('should format non-hash filenames using getOptionLabel', () => {
+ const mockGetOptionLabel = vi.fn((value) => `Formatted ${value}`)
+
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'file',
+ value: 'regular-file.png',
+ options: {
+ values: ['regular-file.png'],
+ getOptionLabel: mockGetOptionLabel
+ }
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe('Formatted regular-file.png')
+ expect(mockGetOptionLabel).toHaveBeenCalledWith('regular-file.png')
+ })
+
+ it('should use getOptionLabel over object value mapping when both present', () => {
+ const mockGetOptionLabel = vi.fn((value) => `Label: ${value}`)
+
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'quality',
+ value: 'hq',
+ options: {
+ values: {
+ hq: 'High Quality',
+ mq: 'Medium Quality'
+ },
+ getOptionLabel: mockGetOptionLabel
+ }
+ }),
+ node
+ )
+
+ // getOptionLabel should take precedence over object value mapping
+ expect(widget._displayValue).toBe('Label: hq')
+ expect(mockGetOptionLabel).toHaveBeenCalledWith('hq')
+ })
+
+ it('should format number values using getOptionLabel when provided', () => {
+ const mockGetOptionLabel = vi.fn((value) => `Number: ${value}`)
+
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'index',
+ value: 42,
+ options: {
+ values: ['0', '1', '42'],
+ getOptionLabel: mockGetOptionLabel
+ }
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe('Number: 42')
+ expect(mockGetOptionLabel).toHaveBeenCalledWith('42')
+ })
+ })
+
+ describe('onClick', () => {
+ it('should show dropdown with formatted labels', () => {
+ const mockGetOptionLabel = vi
+ .fn()
+ .mockReturnValueOnce('Beautiful Sunset.png')
+ .mockReturnValueOnce('Mountain Vista.jpg')
+
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'image',
+ value: HASH_FILENAME,
+ options: {
+ values: [HASH_FILENAME, HASH_FILENAME_2],
+ getOptionLabel: mockGetOptionLabel
+ }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ const mockAddItem = vi.fn()
+ const mockContextMenu = vi.fn(() => ({ addItem: mockAddItem }))
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ // Should show formatted labels in dropdown
+ expect(mockContextMenu).toHaveBeenCalledWith(
+ [],
+ expect.objectContaining({
+ scale: 1,
+ event: mockEvent,
+ className: 'dark'
+ })
+ )
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ 'Beautiful Sunset.png',
+ HASH_FILENAME,
+ expect.objectContaining({
+ callback: expect.any(Function),
+ className: 'dark'
+ })
+ )
+ expect(mockAddItem).toHaveBeenCalledWith(
+ 'Mountain Vista.jpg',
+ HASH_FILENAME_2,
+ expect.objectContaining({
+ callback: expect.any(Function),
+ className: 'dark'
+ })
+ )
+ })
+
+ it('should set original value when selecting formatted label from dropdown', () => {
+ const mockGetOptionLabel = vi
+ .fn()
+ .mockReturnValueOnce('Beautiful Sunset.png')
+ .mockReturnValueOnce('Mountain Vista.jpg')
+
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'image',
+ value: HASH_FILENAME,
+ options: {
+ values: [HASH_FILENAME, HASH_FILENAME_2],
+ getOptionLabel: mockGetOptionLabel
+ }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ const mockAddItem = vi.fn()
+ let capturedCallback: ((value: string) => void) | undefined
+ const mockContextMenu = vi.fn((_values, options) => {
+ capturedCallback = options.callback
+ return { addItem: mockAddItem }
+ })
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ // Simulate selecting second item (Mountain Vista.jpg -> HASH_FILENAME_2)
+ capturedCallback?.(HASH_FILENAME_2)
+
+ // Should set the actual hash value, not the formatted label
+ expect(setValueSpy).toHaveBeenCalledWith(HASH_FILENAME_2, {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+
+ it('should preserve value identity when multiple options have same display label', () => {
+ const mockGetOptionLabel = vi
+ .fn()
+ .mockReturnValueOnce('sunset.png')
+ .mockReturnValueOnce('sunset.png') // Same label, different values
+ .mockReturnValueOnce('mountain.png')
+
+ const hash1 = HASH_FILENAME
+ const hash2 = HASH_FILENAME_2
+ const hash3 = 'abc123def456.png'
+
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'image',
+ value: hash1,
+ options: {
+ values: [hash1, hash2, hash3],
+ getOptionLabel: mockGetOptionLabel
+ }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ const mockAddItem = vi.fn()
+ let capturedCallback: ((value: string) => void) | undefined
+
+ const mockContextMenu = vi.fn((_values, options) => {
+ capturedCallback = options.callback
+ return { addItem: mockAddItem } as ContextMenuInstance
+ })
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ // Should use addItem API with separate name/value
+ expect(mockAddItem).toHaveBeenCalledWith(
+ 'sunset.png',
+ hash1,
+ expect.objectContaining({
+ callback: expect.any(Function),
+ className: 'dark'
+ })
+ )
+ expect(mockAddItem).toHaveBeenCalledWith(
+ 'sunset.png',
+ hash2,
+ expect.objectContaining({
+ callback: expect.any(Function),
+ className: 'dark'
+ })
+ )
+ expect(mockAddItem).toHaveBeenCalledWith(
+ 'mountain.png',
+ hash3,
+ expect.objectContaining({
+ callback: expect.any(Function),
+ className: 'dark'
+ })
+ )
+
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+
+ // Simulate selecting the SECOND "sunset.png" (should pass hash2 directly)
+ capturedCallback?.(hash2)
+
+ // Should set hash2, not hash1 (fixes duplicate name bug)
+ expect(setValueSpy).toHaveBeenCalledWith(hash2, {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+
+ it('should handle getOptionLabel error in dropdown gracefully', () => {
+ const mockGetOptionLabel = vi
+ .fn()
+ .mockReturnValueOnce('Beautiful Sunset.png')
+ .mockImplementationOnce(() => {
+ throw new Error('Formatting failed')
+ })
+
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'image',
+ value: HASH_FILENAME,
+ options: {
+ values: [HASH_FILENAME, HASH_FILENAME_2],
+ getOptionLabel: mockGetOptionLabel
+ }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {})
+
+ const mockAddItem = vi.fn()
+ const mockContextMenu = vi.fn(() => {
+ return { addItem: mockAddItem } as ContextMenuInstance
+ })
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ // Should show formatted label for first, fallback to hash for second
+ expect(mockAddItem).toHaveBeenCalledWith(
+ 'Beautiful Sunset.png',
+ HASH_FILENAME,
+ expect.objectContaining({
+ callback: expect.any(Function),
+ className: 'dark'
+ })
+ )
+ expect(mockAddItem).toHaveBeenCalledWith(
+ HASH_FILENAME_2,
+ HASH_FILENAME_2,
+ expect.objectContaining({
+ callback: expect.any(Function),
+ className: 'dark'
+ })
+ )
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to map value:',
+ expect.any(Error)
+ )
+
+ consoleErrorSpy.mockRestore()
+ })
+
+ it('should show hash values in dropdown when getOptionLabel not provided', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'image',
+ value: HASH_FILENAME,
+ options: {
+ values: [HASH_FILENAME, HASH_FILENAME_2]
+ }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ const mockContextMenu = vi.fn()
+ LiteGraph.ContextMenu =
+ mockContextMenu as unknown as typeof LiteGraph.ContextMenu
+
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+
+ // Should show hash filenames directly (no formatting)
+ expect(mockContextMenu).toHaveBeenCalledWith(
+ [HASH_FILENAME, HASH_FILENAME_2],
+ expect.objectContaining({
+ scale: 1,
+ event: mockEvent,
+ className: 'dark'
+ })
+ )
+ })
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should return empty display value and disallow increment/decrement for empty values', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: '',
+ options: { values: [] }
+ }),
+ node
+ )
+
+ expect(widget._displayValue).toBe('')
+ expect(widget.canIncrement()).toBe(false)
+ expect(widget.canDecrement()).toBe(false)
+ })
+
+ it('should throw error when values is null in getValues', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'test',
+ options: { values: null as any }
+ }),
+ node
+ )
+
+ const mockCanvas = { ds: { scale: 1 } } as LGraphCanvasType
+ const mockEvent = { canvasX: 150 } as CanvasPointerEvent
+ node.pos = [50, 50]
+ node.size = [200, 30]
+
+ expect(() => {
+ widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
+ }).toThrow('[ComboWidget]: values is required')
+ })
+
+ it('should default to first value when incrementing from invalid value', () => {
+ widget = new ComboWidget(
+ createMockWidgetConfig({
+ name: 'mode',
+ value: 'nonexistent',
+ options: { values: ['fast', 'medium', 'slow'] }
+ }),
+ node
+ )
+
+ const { mockCanvas, mockEvent } = setupIncrementDecrementTest()
+
+ const setValueSpy = vi.spyOn(widget, 'setValue')
+ widget.incrementValue({ e: mockEvent, node, canvas: mockCanvas })
+
+ // When value not found (indexOf returns -1), -1 + 1 = 0, clamped to 0
+ expect(setValueSpy).toHaveBeenCalledWith('fast', {
+ e: mockEvent,
+ node,
+ canvas: mockCanvas
+ })
+ })
+ })
+})
diff --git a/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap b/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap
index 18cc5c713..1a92319bb 100644
--- a/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap
+++ b/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap
@@ -274,6 +274,7 @@ LGraph {
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
+ "floatingLinksInternal": Map {},
"globaltime": 0,
"id": "b4e984f1-b421-4d24-b8b4-ff895793af13",
"iteration": 0,
@@ -284,6 +285,7 @@ LGraph {
"nodes_executedAction": [],
"nodes_executing": [],
"onTrigger": undefined,
+ "reroutesInternal": Map {},
"revision": 0,
"runningtime": 0,
"starttime": 0,
diff --git a/tests-ui/tests/litegraph/core/contextMenuCompat.test.ts b/tests-ui/tests/litegraph/core/contextMenuCompat.test.ts
new file mode 100644
index 000000000..623082c68
--- /dev/null
+++ b/tests-ui/tests/litegraph/core/contextMenuCompat.test.ts
@@ -0,0 +1,346 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
+import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
+
+describe('contextMenuCompat', () => {
+ let originalGetCanvasMenuOptions: typeof LGraphCanvas.prototype.getCanvasMenuOptions
+ let mockCanvas: LGraphCanvas
+
+ beforeEach(() => {
+ // Save original method
+ originalGetCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
+
+ // Create mock canvas
+ mockCanvas = {
+ constructor: {
+ prototype: LGraphCanvas.prototype
+ }
+ } as unknown as LGraphCanvas
+
+ // Clear console warnings
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ // Restore original method
+ LGraphCanvas.prototype.getCanvasMenuOptions = originalGetCanvasMenuOptions
+ vi.restoreAllMocks()
+ })
+
+ describe('install', () => {
+ it('should install compatibility layer on prototype', () => {
+ const methodName = 'getCanvasMenuOptions'
+
+ // Install compatibility layer
+ legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
+
+ // The method should still be callable
+ expect(typeof LGraphCanvas.prototype.getCanvasMenuOptions).toBe(
+ 'function'
+ )
+ })
+
+ it('should detect monkey patches and warn', () => {
+ const methodName = 'getCanvasMenuOptions'
+ const warnSpy = vi.spyOn(console, 'warn')
+
+ // Install compatibility layer
+ legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
+
+ // Set current extension before monkey-patching
+ legacyMenuCompat.setCurrentExtension('Test Extension')
+
+ // Simulate extension monkey-patching
+ const original = LGraphCanvas.prototype.getCanvasMenuOptions
+ LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
+ const items = (original as any).apply(this, args)
+ items.push({ content: 'Custom Item', callback: () => {} })
+ return items
+ }
+
+ // Should have logged a warning with extension name
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('[DEPRECATED]'),
+ expect.any(String),
+ expect.any(String)
+ )
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('"Test Extension"'),
+ expect.any(String),
+ expect.any(String)
+ )
+
+ // Clear extension
+ legacyMenuCompat.setCurrentExtension(null)
+ })
+
+ it('should only warn once per unique function', () => {
+ const methodName = 'getCanvasMenuOptions'
+ const warnSpy = vi.spyOn(console, 'warn')
+
+ legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
+ legacyMenuCompat.setCurrentExtension('test.extension')
+
+ const patchFunction = function (this: LGraphCanvas, ...args: any[]) {
+ const items = (originalGetCanvasMenuOptions as any).apply(this, args)
+ items.push({ content: 'Custom', callback: () => {} })
+ return items
+ }
+
+ // Patch twice with same function
+ LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction
+ LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction
+
+ // Should only warn once
+ expect(warnSpy).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('extractLegacyItems', () => {
+ beforeEach(() => {
+ // Setup a mock original method
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
+ return [
+ { content: 'Item 1', callback: () => {} },
+ { content: 'Item 2', callback: () => {} }
+ ]
+ }
+
+ // Install compatibility layer
+ legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
+ })
+
+ it('should extract items added by monkey patches', () => {
+ // Monkey-patch to add items
+ const original = LGraphCanvas.prototype.getCanvasMenuOptions
+ LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
+ const items = (original as any).apply(this, args)
+ items.push({ content: 'Custom Item 1', callback: () => {} })
+ items.push({ content: 'Custom Item 2', callback: () => {} })
+ return items
+ }
+
+ // Extract legacy items
+ const legacyItems = legacyMenuCompat.extractLegacyItems(
+ 'getCanvasMenuOptions',
+ mockCanvas
+ )
+
+ expect(legacyItems).toHaveLength(2)
+ expect(legacyItems[0]).toMatchObject({ content: 'Custom Item 1' })
+ expect(legacyItems[1]).toMatchObject({ content: 'Custom Item 2' })
+ })
+
+ it('should return empty array when no items added', () => {
+ // No monkey-patching, so no extra items
+ const legacyItems = legacyMenuCompat.extractLegacyItems(
+ 'getCanvasMenuOptions',
+ mockCanvas
+ )
+
+ expect(legacyItems).toHaveLength(0)
+ })
+
+ it('should return empty array when patched method returns same count', () => {
+ // Monkey-patch that replaces items but keeps same count
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
+ return [
+ { content: 'Replaced 1', callback: () => {} },
+ { content: 'Replaced 2', callback: () => {} }
+ ]
+ }
+
+ const legacyItems = legacyMenuCompat.extractLegacyItems(
+ 'getCanvasMenuOptions',
+ mockCanvas
+ )
+
+ expect(legacyItems).toHaveLength(0)
+ })
+
+ it('should handle errors gracefully', () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ // Monkey-patch that throws error
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
+ throw new Error('Test error')
+ }
+
+ const legacyItems = legacyMenuCompat.extractLegacyItems(
+ 'getCanvasMenuOptions',
+ mockCanvas
+ )
+
+ expect(legacyItems).toHaveLength(0)
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to extract legacy items'),
+ expect.any(Error)
+ )
+ })
+ })
+
+ describe('integration', () => {
+ it('should work with multiple extensions patching', () => {
+ // Setup base method
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
+ return [{ content: 'Base Item', callback: () => {} }]
+ }
+
+ legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
+
+ // First extension patches
+ const original1 = LGraphCanvas.prototype.getCanvasMenuOptions
+ LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
+ const items = (original1 as any).apply(this, args)
+ items.push({ content: 'Extension 1 Item', callback: () => {} })
+ return items
+ }
+
+ // Second extension patches
+ const original2 = LGraphCanvas.prototype.getCanvasMenuOptions
+ LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
+ const items = (original2 as any).apply(this, args)
+ items.push({ content: 'Extension 2 Item', callback: () => {} })
+ return items
+ }
+
+ // Extract legacy items
+ const legacyItems = legacyMenuCompat.extractLegacyItems(
+ 'getCanvasMenuOptions',
+ mockCanvas
+ )
+
+ // Should extract both items added by extensions
+ expect(legacyItems).toHaveLength(2)
+ expect(legacyItems[0]).toMatchObject({ content: 'Extension 1 Item' })
+ expect(legacyItems[1]).toMatchObject({ content: 'Extension 2 Item' })
+ })
+
+ it('should extract legacy items only once even when called multiple times', () => {
+ // Setup base method
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
+ return [
+ { content: 'Base Item 1', callback: () => {} },
+ { content: 'Base Item 2', callback: () => {} }
+ ]
+ }
+
+ legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
+
+ // Simulate legacy extension monkey-patching the prototype
+ const original = LGraphCanvas.prototype.getCanvasMenuOptions
+ LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
+ const items = (original as any).apply(this, args)
+ items.push({ content: 'Legacy Item 1', callback: () => {} })
+ items.push({ content: 'Legacy Item 2', callback: () => {} })
+ return items
+ }
+
+ // Extract legacy items multiple times (simulating repeated menu opens)
+ const legacyItems1 = legacyMenuCompat.extractLegacyItems(
+ 'getCanvasMenuOptions',
+ mockCanvas
+ )
+ const legacyItems2 = legacyMenuCompat.extractLegacyItems(
+ 'getCanvasMenuOptions',
+ mockCanvas
+ )
+ const legacyItems3 = legacyMenuCompat.extractLegacyItems(
+ 'getCanvasMenuOptions',
+ mockCanvas
+ )
+
+ // Each extraction should return the same items (no accumulation)
+ expect(legacyItems1).toHaveLength(2)
+ expect(legacyItems2).toHaveLength(2)
+ expect(legacyItems3).toHaveLength(2)
+
+ // Verify items are the expected ones
+ expect(legacyItems1[0]).toMatchObject({ content: 'Legacy Item 1' })
+ expect(legacyItems1[1]).toMatchObject({ content: 'Legacy Item 2' })
+
+ expect(legacyItems2[0]).toMatchObject({ content: 'Legacy Item 1' })
+ expect(legacyItems2[1]).toMatchObject({ content: 'Legacy Item 2' })
+
+ expect(legacyItems3[0]).toMatchObject({ content: 'Legacy Item 1' })
+ expect(legacyItems3[1]).toMatchObject({ content: 'Legacy Item 2' })
+ })
+
+ it('should not extract items from registered wrapper methods', () => {
+ // Setup base method
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
+ return [{ content: 'Base Item', callback: () => {} }]
+ }
+
+ legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
+
+ // Create a wrapper that adds new API items (simulating useContextMenuTranslation)
+ const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
+ const wrapperMethod = function (this: LGraphCanvas) {
+ const items = (originalMethod as any).apply(this, [])
+ // Add new API items
+ items.push({ content: 'New API Item 1', callback: () => {} })
+ items.push({ content: 'New API Item 2', callback: () => {} })
+ return items
+ }
+
+ // Set the wrapper as the current method
+ LGraphCanvas.prototype.getCanvasMenuOptions = wrapperMethod
+
+ // Register the wrapper so it's not treated as a legacy patch
+ legacyMenuCompat.registerWrapper(
+ 'getCanvasMenuOptions',
+ wrapperMethod,
+ originalMethod,
+ LGraphCanvas.prototype // Wrapper is installed
+ )
+
+ // Extract legacy items - should return empty because current method is a registered wrapper
+ const legacyItems = legacyMenuCompat.extractLegacyItems(
+ 'getCanvasMenuOptions',
+ mockCanvas
+ )
+
+ expect(legacyItems).toHaveLength(0)
+ })
+
+ it('should extract legacy items even when a wrapper is registered but not active', () => {
+ // Setup base method
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
+ return [{ content: 'Base Item', callback: () => {} }]
+ }
+
+ legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
+
+ // Register a wrapper (but don't set it as the current method)
+ const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
+ const wrapperMethod = function () {
+ return [{ content: 'Wrapper Item', callback: () => {} }]
+ }
+ legacyMenuCompat.registerWrapper(
+ 'getCanvasMenuOptions',
+ wrapperMethod,
+ originalMethod
+ // NOT passing prototype, so it won't be marked as installed
+ )
+
+ // Monkey-patch with a different function (legacy extension)
+ const original = LGraphCanvas.prototype.getCanvasMenuOptions
+ LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
+ const items = (original as any).apply(this, args)
+ items.push({ content: 'Legacy Item', callback: () => {} })
+ return items
+ }
+
+ // Extract legacy items - should return the legacy item because current method is NOT the wrapper
+ const legacyItems = legacyMenuCompat.extractLegacyItems(
+ 'getCanvasMenuOptions',
+ mockCanvas
+ )
+
+ expect(legacyItems).toHaveLength(1)
+ expect(legacyItems[0]).toMatchObject({ content: 'Legacy Item' })
+ })
+ })
+})
diff --git a/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts b/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts
new file mode 100644
index 000000000..b7bff37a5
--- /dev/null
+++ b/tests-ui/tests/platform/cloud/subscription/components/SubscriptionPanel.test.ts
@@ -0,0 +1,213 @@
+import { createTestingPinia } from '@pinia/testing'
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createI18n } from 'vue-i18n'
+
+import SubscriptionPanel from '@/platform/cloud/subscription/components/SubscriptionPanel.vue'
+
+// Mock composables
+const mockSubscriptionData = {
+ isActiveSubscription: false,
+ isCancelled: false,
+ formattedRenewalDate: '2024-12-31',
+ formattedEndDate: '2024-12-31',
+ formattedMonthlyPrice: '$9.99',
+ manageSubscription: vi.fn(),
+ handleInvoiceHistory: vi.fn()
+}
+
+const mockCreditsData = {
+ totalCredits: '10.00',
+ monthlyBonusCredits: '5.00',
+ prepaidCredits: '5.00',
+ isLoadingBalance: false
+}
+
+const mockActionsData = {
+ isLoadingSupport: false,
+ refreshTooltip: 'Refreshes on 2024-12-31',
+ handleAddApiCredits: vi.fn(),
+ handleMessageSupport: vi.fn(),
+ handleRefresh: vi.fn(),
+ handleLearnMoreClick: vi.fn()
+}
+
+vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
+ useSubscription: () => mockSubscriptionData
+}))
+
+vi.mock(
+ '@/platform/cloud/subscription/composables/useSubscriptionCredits',
+ () => ({
+ useSubscriptionCredits: () => mockCreditsData
+ })
+)
+
+vi.mock(
+ '@/platform/cloud/subscription/composables/useSubscriptionActions',
+ () => ({
+ useSubscriptionActions: () => mockActionsData
+ })
+)
+
+// Create i18n instance for testing
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ subscription: {
+ title: 'Subscription',
+ perMonth: '/ month',
+ subscribeNow: 'Subscribe Now',
+ manageSubscription: 'Manage Subscription',
+ partnerNodesBalance: 'Partner Nodes Balance',
+ partnerNodesDescription: 'Credits for partner nodes',
+ totalCredits: 'Total Credits',
+ monthlyBonusDescription: 'Monthly bonus',
+ prepaidDescription: 'Prepaid credits',
+ monthlyCreditsRollover: 'Monthly credits rollover info',
+ prepaidCreditsInfo: 'Prepaid credits info',
+ viewUsageHistory: 'View Usage History',
+ addCredits: 'Add Credits',
+ yourPlanIncludes: 'Your plan includes',
+ learnMore: 'Learn More',
+ messageSupport: 'Message Support',
+ invoiceHistory: 'Invoice History',
+ renewsDate: 'Renews {date}',
+ expiresDate: 'Expires {date}'
+ }
+ }
+ }
+})
+
+function createWrapper(overrides = {}) {
+ return mount(SubscriptionPanel, {
+ global: {
+ plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
+ stubs: {
+ CloudBadge: true,
+ SubscribeButton: true,
+ SubscriptionBenefits: true,
+ Button: {
+ template:
+ '',
+ props: [
+ 'loading',
+ 'label',
+ 'icon',
+ 'text',
+ 'severity',
+ 'size',
+ 'iconPos',
+ 'pt'
+ ],
+ emits: ['click']
+ },
+ Skeleton: {
+ template: ''
+ }
+ }
+ },
+ ...overrides
+ })
+}
+
+describe('SubscriptionPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('subscription state functionality', () => {
+ it('shows correct UI for active subscription', () => {
+ mockSubscriptionData.isActiveSubscription = true
+ const wrapper = createWrapper()
+ expect(wrapper.text()).toContain('Manage Subscription')
+ expect(wrapper.text()).toContain('Add Credits')
+ })
+
+ it('shows correct UI for inactive subscription', () => {
+ mockSubscriptionData.isActiveSubscription = false
+ const wrapper = createWrapper()
+ expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
+ true
+ )
+ expect(wrapper.text()).not.toContain('Manage Subscription')
+ expect(wrapper.text()).not.toContain('Add Credits')
+ })
+
+ it('shows renewal date for active non-cancelled subscription', () => {
+ mockSubscriptionData.isActiveSubscription = true
+ mockSubscriptionData.isCancelled = false
+ const wrapper = createWrapper()
+ expect(wrapper.text()).toContain('Renews 2024-12-31')
+ })
+
+ it('shows expiry date for cancelled subscription', () => {
+ mockSubscriptionData.isActiveSubscription = true
+ mockSubscriptionData.isCancelled = true
+ const wrapper = createWrapper()
+ expect(wrapper.text()).toContain('Expires 2024-12-31')
+ })
+ })
+
+ describe('credit display functionality', () => {
+ it('displays dynamic credit values correctly', () => {
+ const wrapper = createWrapper()
+ expect(wrapper.text()).toContain('$10.00') // totalCredits
+ expect(wrapper.text()).toContain('$5.00') // both monthlyBonus and prepaid
+ })
+
+ it('shows loading skeleton when fetching balance', () => {
+ mockCreditsData.isLoadingBalance = true
+ const wrapper = createWrapper()
+ expect(wrapper.findAll('.skeleton').length).toBeGreaterThan(0)
+ })
+
+ it('hides skeleton when balance loaded', () => {
+ mockCreditsData.isLoadingBalance = false
+ const wrapper = createWrapper()
+ expect(wrapper.findAll('.skeleton').length).toBe(0)
+ })
+ })
+
+ describe('action buttons', () => {
+ it('should call handleLearnMoreClick when learn more is clicked', async () => {
+ const wrapper = createWrapper()
+ const learnMoreButton = wrapper.find('[data-testid="Learn More"]')
+ await learnMoreButton.trigger('click')
+ expect(mockActionsData.handleLearnMoreClick).toHaveBeenCalledOnce()
+ })
+
+ it('should call handleMessageSupport when message support is clicked', async () => {
+ const wrapper = createWrapper()
+ const supportButton = wrapper.find('[data-testid="Message Support"]')
+ await supportButton.trigger('click')
+ expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
+ })
+
+ it('should call handleRefresh when refresh button is clicked', async () => {
+ const wrapper = createWrapper()
+ // Find the refresh button by icon
+ const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
+ await refreshButton.trigger('click')
+ expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
+ })
+ })
+
+ describe('loading states', () => {
+ it('should show loading state on support button when loading', () => {
+ mockActionsData.isLoadingSupport = true
+ const wrapper = createWrapper()
+ const supportButton = wrapper.find('[data-testid="Message Support"]')
+ expect(supportButton.attributes('disabled')).toBeDefined()
+ })
+
+ it('should show loading state on refresh button when loading balance', () => {
+ mockCreditsData.isLoadingBalance = true
+ const wrapper = createWrapper()
+ const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
+ expect(refreshButton.attributes('disabled')).toBeDefined()
+ })
+ })
+})
diff --git a/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionActions.test.ts b/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionActions.test.ts
new file mode 100644
index 000000000..a4c9db49a
--- /dev/null
+++ b/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionActions.test.ts
@@ -0,0 +1,141 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
+
+// Mock dependencies
+const mockFetchBalance = vi.fn()
+const mockFetchStatus = vi.fn()
+const mockShowTopUpCreditsDialog = vi.fn()
+const mockExecute = vi.fn()
+const mockT = vi.fn((key: string) => {
+ if (key === 'subscription.nextBillingCycle') return 'next billing cycle'
+ return key
+})
+
+vi.mock('vue-i18n', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: mockT
+ })
+ }
+})
+
+vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
+ useFirebaseAuthActions: () => ({
+ fetchBalance: mockFetchBalance
+ })
+}))
+
+const mockFormattedRenewalDate = { value: '2024-12-31' }
+
+vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
+ useSubscription: () => ({
+ fetchStatus: mockFetchStatus,
+ formattedRenewalDate: mockFormattedRenewalDate
+ })
+}))
+
+vi.mock('@/services/dialogService', () => ({
+ useDialogService: () => ({
+ showTopUpCreditsDialog: mockShowTopUpCreditsDialog
+ })
+}))
+
+vi.mock('@/stores/commandStore', () => ({
+ useCommandStore: () => ({
+ execute: mockExecute
+ })
+}))
+
+// Mock window.open
+const mockOpen = vi.fn()
+Object.defineProperty(window, 'open', {
+ writable: true,
+ value: mockOpen
+})
+
+describe('useSubscriptionActions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFormattedRenewalDate.value = '2024-12-31'
+ })
+
+ describe('refreshTooltip', () => {
+ it('should format tooltip with renewal date', () => {
+ const { refreshTooltip } = useSubscriptionActions()
+ expect(refreshTooltip.value).toBe('Refreshes on 2024-12-31')
+ })
+
+ it('should use fallback text when no renewal date', () => {
+ mockFormattedRenewalDate.value = ''
+ const { refreshTooltip } = useSubscriptionActions()
+ expect(refreshTooltip.value).toBe('Refreshes on next billing cycle')
+ expect(mockT).toHaveBeenCalledWith('subscription.nextBillingCycle')
+ })
+ })
+
+ describe('handleAddApiCredits', () => {
+ it('should call showTopUpCreditsDialog', () => {
+ const { handleAddApiCredits } = useSubscriptionActions()
+ handleAddApiCredits()
+ expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
+ })
+ })
+
+ describe('handleMessageSupport', () => {
+ it('should execute support command and manage loading state', async () => {
+ const { handleMessageSupport, isLoadingSupport } =
+ useSubscriptionActions()
+
+ expect(isLoadingSupport.value).toBe(false)
+
+ const promise = handleMessageSupport()
+ expect(isLoadingSupport.value).toBe(true)
+
+ await promise
+ expect(mockExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
+ expect(isLoadingSupport.value).toBe(false)
+ })
+
+ it('should handle errors gracefully', async () => {
+ mockExecute.mockRejectedValueOnce(new Error('Command failed'))
+ const { handleMessageSupport, isLoadingSupport } =
+ useSubscriptionActions()
+
+ await handleMessageSupport()
+ expect(isLoadingSupport.value).toBe(false)
+ })
+ })
+
+ describe('handleRefresh', () => {
+ it('should call both fetchBalance and fetchStatus', async () => {
+ const { handleRefresh } = useSubscriptionActions()
+ await handleRefresh()
+
+ expect(mockFetchBalance).toHaveBeenCalledOnce()
+ expect(mockFetchStatus).toHaveBeenCalledOnce()
+ })
+
+ it('should handle errors gracefully', async () => {
+ mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
+ const { handleRefresh } = useSubscriptionActions()
+
+ // Should not throw
+ await expect(handleRefresh()).resolves.toBeUndefined()
+ })
+ })
+
+ describe('handleLearnMoreClick', () => {
+ it('should open learn more URL', () => {
+ const { handleLearnMoreClick } = useSubscriptionActions()
+ handleLearnMoreClick()
+
+ expect(mockOpen).toHaveBeenCalledWith(
+ 'https://docs.comfy.org/get_started/cloud',
+ '_blank'
+ )
+ })
+ })
+})
diff --git a/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts b/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts
new file mode 100644
index 000000000..1d64129a4
--- /dev/null
+++ b/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts
@@ -0,0 +1,146 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
+import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
+
+// Mock Firebase Auth and related modules
+vi.mock('vuefire', () => ({
+ useFirebaseAuth: vi.fn(() => ({
+ onAuthStateChanged: vi.fn(),
+ setPersistence: vi.fn()
+ }))
+}))
+
+vi.mock('firebase/auth', () => ({
+ onAuthStateChanged: vi.fn(() => {
+ // Mock the callback to be called immediately for testing
+ return vi.fn()
+ }),
+ onIdTokenChanged: vi.fn(),
+ setPersistence: vi.fn().mockResolvedValue(undefined),
+ browserLocalPersistence: {},
+ GoogleAuthProvider: class {
+ addScope = vi.fn()
+ setCustomParameters = vi.fn()
+ },
+ GithubAuthProvider: class {
+ addScope = vi.fn()
+ setCustomParameters = vi.fn()
+ }
+}))
+
+// Mock other dependencies
+vi.mock('@/services/dialogService', () => ({
+ useDialogService: () => ({
+ showDialog: vi.fn()
+ })
+}))
+
+vi.mock('@/platform/telemetry', () => ({
+ useTelemetry: () => ({
+ track: vi.fn()
+ })
+}))
+
+vi.mock('@/stores/toastStore', () => ({
+ useToastStore: () => ({
+ add: vi.fn()
+ })
+}))
+
+vi.mock('@/stores/apiKeyAuthStore', () => ({
+ useApiKeyAuthStore: () => ({
+ headers: {}
+ })
+}))
+
+// Mock formatMetronomeCurrency
+vi.mock('@/utils/formatUtil', () => ({
+ formatMetronomeCurrency: vi.fn((micros: number) => {
+ // Simple mock that converts micros to dollars
+ return (micros / 1000000).toFixed(2)
+ })
+}))
+
+describe('useSubscriptionCredits', () => {
+ let authStore: ReturnType
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ authStore = useFirebaseAuthStore()
+ vi.clearAllMocks()
+ })
+
+ describe('totalCredits', () => {
+ it('should return "0.00" when balance is null', () => {
+ authStore.balance = null
+ const { totalCredits } = useSubscriptionCredits()
+ expect(totalCredits.value).toBe('0.00')
+ })
+
+ it('should return "0.00" when amount_micros is missing', () => {
+ authStore.balance = {} as any
+ const { totalCredits } = useSubscriptionCredits()
+ expect(totalCredits.value).toBe('0.00')
+ })
+
+ it('should format amount_micros correctly', () => {
+ authStore.balance = { amount_micros: 5000000 } as any
+ const { totalCredits } = useSubscriptionCredits()
+ expect(totalCredits.value).toBe('5.00')
+ })
+
+ it('should handle formatting errors gracefully', async () => {
+ const mockFormatMetronomeCurrency = vi.mocked(
+ await import('@/utils/formatUtil')
+ ).formatMetronomeCurrency
+ mockFormatMetronomeCurrency.mockImplementationOnce(() => {
+ throw new Error('Formatting error')
+ })
+
+ authStore.balance = { amount_micros: 5000000 } as any
+ const { totalCredits } = useSubscriptionCredits()
+ expect(totalCredits.value).toBe('0.00')
+ })
+ })
+
+ describe('monthlyBonusCredits', () => {
+ it('should return "0.00" when cloud_credit_balance_micros is missing', () => {
+ authStore.balance = {} as any
+ const { monthlyBonusCredits } = useSubscriptionCredits()
+ expect(monthlyBonusCredits.value).toBe('0.00')
+ })
+
+ it('should format cloud_credit_balance_micros correctly', () => {
+ authStore.balance = { cloud_credit_balance_micros: 2500000 } as any
+ const { monthlyBonusCredits } = useSubscriptionCredits()
+ expect(monthlyBonusCredits.value).toBe('2.50')
+ })
+ })
+
+ describe('prepaidCredits', () => {
+ it('should return "0.00" when prepaid_balance_micros is missing', () => {
+ authStore.balance = {} as any
+ const { prepaidCredits } = useSubscriptionCredits()
+ expect(prepaidCredits.value).toBe('0.00')
+ })
+
+ it('should format prepaid_balance_micros correctly', () => {
+ authStore.balance = { prepaid_balance_micros: 7500000 } as any
+ const { prepaidCredits } = useSubscriptionCredits()
+ expect(prepaidCredits.value).toBe('7.50')
+ })
+ })
+
+ describe('isLoadingBalance', () => {
+ it('should reflect authStore.isFetchingBalance', () => {
+ authStore.isFetchingBalance = true
+ const { isLoadingBalance } = useSubscriptionCredits()
+ expect(isLoadingBalance.value).toBe(true)
+
+ authStore.isFetchingBalance = false
+ expect(isLoadingBalance.value).toBe(false)
+ })
+ })
+})
diff --git a/tests-ui/tests/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts b/tests-ui/tests/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts
index f77a5e158..c047782a0 100644
--- a/tests-ui/tests/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts
+++ b/tests-ui/tests/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts
@@ -11,6 +11,23 @@ import {
expectedV1Fixture,
historyV2Fixture
} from '@tests-ui/fixtures/historyFixtures'
+import {
+ historyV2FiveItemsSorting,
+ historyV2MultipleNoTimestamp,
+ historyV2WithMissingTimestamp
+} from '@tests-ui/fixtures/historySortingFixtures'
+import type { HistoryTaskItem } from '@/platform/remote/comfyui/history/types/historyV1Types'
+
+function findResultByPromptId(
+ result: HistoryTaskItem[],
+ promptId: string
+): HistoryTaskItem {
+ const item = result.find((item) => item.prompt[1] === promptId)
+ if (!item) {
+ throw new Error(`Expected item with promptId ${promptId} not found`)
+ }
+ return item
+}
describe('mapHistoryV2toHistory', () => {
describe('fixture validation', () => {
@@ -38,7 +55,7 @@ describe('mapHistoryV2toHistory', () => {
it('should transform prompt to V1 tuple [priority, id, {}, extra_data, outputNodeIds]', () => {
const firstItem = history[0]
- expect(firstItem.prompt[0]).toBe(24)
+ expect(firstItem.prompt[0]).toBe(1) // Synthetic priority based on timestamp
expect(firstItem.prompt[1]).toBe('complete-item-id')
expect(firstItem.prompt[2]).toEqual({}) // history v2 does not return this data
expect(firstItem.prompt[3]).toMatchObject({ client_id: 'test-client' })
@@ -117,4 +134,52 @@ describe('mapHistoryV2toHistory', () => {
expect(history[0].prompt[3].client_id).toBeUndefined()
})
})
+
+ describe('timestamp-based priority assignment', () => {
+ it('assigns priority 0 to items without execution_success timestamp', () => {
+ const result = mapHistoryV2toHistory(historyV2WithMissingTimestamp)
+
+ expect(result).toHaveLength(3)
+
+ const item1000 = findResultByPromptId(result, 'item-timestamp-1000')
+ const item2000 = findResultByPromptId(result, 'item-timestamp-2000')
+ const itemNoTimestamp = findResultByPromptId(result, 'item-no-timestamp')
+
+ expect(item2000.prompt[0]).toBe(2)
+ expect(item1000.prompt[0]).toBe(1)
+ expect(itemNoTimestamp.prompt[0]).toBe(0)
+ })
+
+ it('correctly sorts and assigns priorities for multiple items', () => {
+ const result = mapHistoryV2toHistory(historyV2FiveItemsSorting)
+
+ expect(result).toHaveLength(5)
+
+ const item1000 = findResultByPromptId(result, 'item-timestamp-1000')
+ const item2000 = findResultByPromptId(result, 'item-timestamp-2000')
+ const item3000 = findResultByPromptId(result, 'item-timestamp-3000')
+ const item4000 = findResultByPromptId(result, 'item-timestamp-4000')
+ const item5000 = findResultByPromptId(result, 'item-timestamp-5000')
+
+ expect(item5000.prompt[0]).toBe(5)
+ expect(item4000.prompt[0]).toBe(4)
+ expect(item3000.prompt[0]).toBe(3)
+ expect(item2000.prompt[0]).toBe(2)
+ expect(item1000.prompt[0]).toBe(1)
+ })
+
+ it('assigns priority 0 to all items when multiple items lack timestamps', () => {
+ const result = mapHistoryV2toHistory(historyV2MultipleNoTimestamp)
+
+ expect(result).toHaveLength(3)
+
+ const item1 = findResultByPromptId(result, 'item-no-timestamp-1')
+ const item2 = findResultByPromptId(result, 'item-no-timestamp-2')
+ const item3 = findResultByPromptId(result, 'item-no-timestamp-3')
+
+ expect(item1.prompt[0]).toBe(0)
+ expect(item2.prompt[0]).toBe(0)
+ expect(item3.prompt[0]).toBe(0)
+ })
+ })
})
diff --git a/tests-ui/tests/platform/remote/comfyui/history/reconciliation.test.ts b/tests-ui/tests/platform/remote/comfyui/history/reconciliation.test.ts
new file mode 100644
index 000000000..d7ada5971
--- /dev/null
+++ b/tests-ui/tests/platform/remote/comfyui/history/reconciliation.test.ts
@@ -0,0 +1,335 @@
+/**
+ * @fileoverview Tests for history reconciliation (V1 and V2)
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { reconcileHistory } from '@/platform/remote/comfyui/history/reconciliation'
+import type { TaskItem } from '@/schemas/apiSchema'
+
+// Mock distribution types
+vi.mock('@/platform/distribution/types', () => ({
+ isCloud: false,
+ isDesktop: true
+}))
+
+function createHistoryItem(promptId: string, queueIndex = 0): TaskItem {
+ return {
+ taskType: 'History',
+ prompt: [queueIndex, promptId, {}, {}, []],
+ status: { status_str: 'success', completed: true, messages: [] },
+ outputs: {}
+ }
+}
+
+function getAllPromptIds(result: TaskItem[]): string[] {
+ return result.map((item) => item.prompt[1])
+}
+
+describe('reconcileHistory (V1)', () => {
+ beforeEach(async () => {
+ const distTypes = await import('@/platform/distribution/types')
+ vi.mocked(distTypes).isCloud = false
+ })
+
+ describe('when filtering by queueIndex', () => {
+ it('should retain items with queueIndex greater than lastKnownQueueIndex', () => {
+ const serverHistory = [
+ createHistoryItem('new-1', 11),
+ createHistoryItem('new-2', 10),
+ createHistoryItem('old', 5)
+ ]
+ const clientHistory = [createHistoryItem('old', 5)]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 10, 9)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(3)
+ expect(promptIds).toContain('new-1')
+ expect(promptIds).toContain('new-2')
+ expect(promptIds).toContain('old')
+ })
+
+ it('should evict items with queueIndex less than or equal to lastKnownQueueIndex', () => {
+ const serverHistory = [
+ createHistoryItem('new', 11),
+ createHistoryItem('existing', 10),
+ createHistoryItem('old-should-not-appear', 5)
+ ]
+ const clientHistory = [createHistoryItem('existing', 10)]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 10, 10)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(2)
+ expect(promptIds).toContain('new')
+ expect(promptIds).toContain('existing')
+ expect(promptIds).not.toContain('old-should-not-appear')
+ })
+
+ it('should retain all server items when lastKnownQueueIndex is undefined', () => {
+ const serverHistory = [
+ createHistoryItem('item-1', 5),
+ createHistoryItem('item-2', 4)
+ ]
+
+ const result = reconcileHistory(serverHistory, [], 10, undefined)
+
+ expect(result).toHaveLength(2)
+ expect(result[0].prompt[1]).toBe('item-1')
+ expect(result[1].prompt[1]).toBe('item-2')
+ })
+ })
+
+ describe('when reconciling with existing client items', () => {
+ it('should retain client items that still exist on server', () => {
+ const serverHistory = [
+ createHistoryItem('new', 11),
+ createHistoryItem('existing-1', 9),
+ createHistoryItem('existing-2', 8)
+ ]
+ const clientHistory = [
+ createHistoryItem('existing-1', 9),
+ createHistoryItem('existing-2', 8)
+ ]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 10, 10)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(3)
+ expect(promptIds).toContain('new')
+ expect(promptIds).toContain('existing-1')
+ expect(promptIds).toContain('existing-2')
+ })
+
+ it('should evict client items that no longer exist on server', () => {
+ const serverHistory = [
+ createHistoryItem('new', 11),
+ createHistoryItem('keep', 9)
+ ]
+ const clientHistory = [
+ createHistoryItem('keep', 9),
+ createHistoryItem('removed-from-server', 8)
+ ]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 10, 10)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(2)
+ expect(promptIds).toContain('new')
+ expect(promptIds).toContain('keep')
+ expect(promptIds).not.toContain('removed-from-server')
+ })
+ })
+
+ describe('when limiting the result count', () => {
+ it('should respect the maxItems constraint', () => {
+ const serverHistory = Array.from({ length: 10 }, (_, i) =>
+ createHistoryItem(`item-${i}`, 20 + i)
+ )
+
+ const result = reconcileHistory(serverHistory, [], 5, 15)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(5)
+ })
+
+ it('should evict lowest priority items when exceeding capacity', () => {
+ const serverHistory = [
+ createHistoryItem('new-1', 13),
+ createHistoryItem('new-2', 12),
+ createHistoryItem('new-3', 11),
+ createHistoryItem('existing', 9)
+ ]
+ const clientHistory = [createHistoryItem('existing', 9)]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 2, 10)
+
+ expect(result).toHaveLength(2)
+ expect(result[0].prompt[1]).toBe('new-1')
+ expect(result[1].prompt[1]).toBe('new-2')
+ })
+ })
+
+ describe('when handling empty collections', () => {
+ it('should return all server items when client history is empty', () => {
+ const serverHistory = [
+ createHistoryItem('item-1', 10),
+ createHistoryItem('item-2', 9)
+ ]
+
+ const result = reconcileHistory(serverHistory, [], 10, 8)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(2)
+ })
+
+ it('should return empty result when server history is empty', () => {
+ const clientHistory = [createHistoryItem('item-1', 5)]
+
+ const result = reconcileHistory([], clientHistory, 10, 5)
+
+ expect(result).toHaveLength(0)
+ })
+
+ it('should return empty result when both collections are empty', () => {
+ const result = reconcileHistory([], [], 10, undefined)
+
+ expect(result).toHaveLength(0)
+ })
+ })
+})
+
+describe('reconcileHistory (V2/Cloud)', () => {
+ beforeEach(async () => {
+ const distTypes = await import('@/platform/distribution/types')
+ vi.mocked(distTypes).isCloud = true
+ })
+
+ describe('when adding new items from server', () => {
+ it('should retain items with promptIds not present in client history', () => {
+ const serverHistory = [
+ createHistoryItem('new-item'),
+ createHistoryItem('existing-item')
+ ]
+ const clientHistory = [createHistoryItem('existing-item')]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 10)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(2)
+ expect(promptIds).toContain('new-item')
+ expect(promptIds).toContain('existing-item')
+ })
+
+ it('should respect priority ordering when retaining multiple new items', () => {
+ const serverHistory = [
+ createHistoryItem('new-1'),
+ createHistoryItem('new-2'),
+ createHistoryItem('existing')
+ ]
+ const clientHistory = [createHistoryItem('existing')]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 10)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(3)
+ expect(promptIds).toContain('new-1')
+ expect(promptIds).toContain('new-2')
+ expect(promptIds).toContain('existing')
+ })
+ })
+
+ describe('when reconciling with existing client items', () => {
+ it('should retain client items that still exist on server', () => {
+ const serverHistory = [
+ createHistoryItem('item-1'),
+ createHistoryItem('item-2')
+ ]
+ const clientHistory = [
+ createHistoryItem('item-1'),
+ createHistoryItem('item-2')
+ ]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 10)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(2)
+ expect(promptIds).toContain('item-1')
+ expect(promptIds).toContain('item-2')
+ })
+
+ it('should evict client items that no longer exist on server', () => {
+ const serverHistory = [createHistoryItem('item-1')]
+ const clientHistory = [
+ createHistoryItem('item-1'),
+ createHistoryItem('old-item')
+ ]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 10)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(1)
+ expect(promptIds).toContain('item-1')
+ expect(promptIds).not.toContain('old-item')
+ })
+ })
+
+ describe('when detecting new items by promptId', () => {
+ it('should retain new items regardless of queueIndex values', () => {
+ const serverHistory = [
+ createHistoryItem('existing', 100),
+ createHistoryItem('new-item', 50)
+ ]
+ const clientHistory = [createHistoryItem('existing', 100)]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 10)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toContain('new-item')
+ expect(promptIds).toContain('existing')
+ })
+ })
+
+ describe('when limiting the result count', () => {
+ it('should respect the maxItems constraint', () => {
+ const serverHistory = Array.from({ length: 10 }, (_, i) =>
+ createHistoryItem(`server-${i}`)
+ )
+ const clientHistory = Array.from({ length: 5 }, (_, i) =>
+ createHistoryItem(`client-${i}`)
+ )
+
+ const result = reconcileHistory(serverHistory, clientHistory, 5)
+
+ const promptIds = getAllPromptIds(result)
+ expect(promptIds).toHaveLength(5)
+ })
+
+ it('should evict lowest priority items when exceeding capacity', () => {
+ const serverHistory = [
+ createHistoryItem('new-1'),
+ createHistoryItem('new-2'),
+ createHistoryItem('existing')
+ ]
+ const clientHistory = [createHistoryItem('existing')]
+
+ const result = reconcileHistory(serverHistory, clientHistory, 2)
+
+ expect(result).toHaveLength(2)
+ expect(result[0].prompt[1]).toBe('new-1')
+ expect(result[1].prompt[1]).toBe('new-2')
+ })
+ })
+
+ describe('when handling empty collections', () => {
+ it('should return all server items when client history is empty', () => {
+ const serverHistory = [
+ createHistoryItem('item-1'),
+ createHistoryItem('item-2')
+ ]
+
+ const result = reconcileHistory(serverHistory, [], 10)
+
+ expect(result).toHaveLength(2)
+ expect(result[0].prompt[1]).toBe('item-1')
+ expect(result[1].prompt[1]).toBe('item-2')
+ })
+
+ it('should return empty result when server history is empty', () => {
+ const clientHistory = [
+ createHistoryItem('item-1'),
+ createHistoryItem('item-2')
+ ]
+
+ const result = reconcileHistory([], clientHistory, 10)
+
+ expect(result).toHaveLength(0)
+ })
+
+ it('should return empty result when both collections are empty', () => {
+ const result = reconcileHistory([], [], 10)
+
+ expect(result).toHaveLength(0)
+ })
+ })
+})
diff --git a/tests-ui/tests/platform/settings/components/SettingItem.test.ts b/tests-ui/tests/platform/settings/components/SettingItem.test.ts
new file mode 100644
index 000000000..c32548f83
--- /dev/null
+++ b/tests-ui/tests/platform/settings/components/SettingItem.test.ts
@@ -0,0 +1,107 @@
+import { flushPromises, shallowMount } from '@vue/test-utils'
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+
+import SettingItem from '@/platform/settings/components/SettingItem.vue'
+import type { SettingParams } from '@/platform/settings/types'
+import { i18n } from '@/i18n'
+
+/**
+ * Verifies that SettingItem emits telemetry when its value changes
+ * and suppresses telemetry when the value remains the same.
+ */
+const trackSettingChanged = vi.fn()
+vi.mock('@/platform/telemetry', () => ({
+ useTelemetry: vi.fn(() => ({
+ trackSettingChanged
+ }))
+}))
+
+const mockGet = vi.fn()
+const mockSet = vi.fn()
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: () => ({
+ get: mockGet,
+ set: mockSet
+ })
+}))
+
+/**
+ * Minimal stub for FormItem that allows emitting `update:form-value`.
+ */
+const FormItemUpdateStub = {
+ template: '',
+ emits: ['update:form-value'],
+ props: ['id', 'item', 'formValue']
+}
+
+describe('SettingItem (telemetry UI tracking)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const mountComponent = (setting: SettingParams) => {
+ return shallowMount(SettingItem, {
+ global: {
+ plugins: [i18n],
+ stubs: {
+ FormItem: FormItemUpdateStub,
+ Tag: true
+ }
+ },
+ props: {
+ setting
+ }
+ })
+ }
+
+ it('tracks telemetry when value changes via UI (uses normalized value)', async () => {
+ const settingParams: SettingParams = {
+ id: 'main.sub.setting.name',
+ name: 'Telemetry Visible',
+ type: 'text',
+ defaultValue: 'default'
+ }
+
+ mockGet.mockReturnValueOnce('default').mockReturnValueOnce('normalized')
+ mockSet.mockResolvedValue(undefined)
+
+ const wrapper = mountComponent(settingParams)
+
+ const newValue = 'newvalue'
+ const formItem = wrapper.findComponent(FormItemUpdateStub)
+ formItem.vm.$emit('update:form-value', newValue)
+
+ await flushPromises()
+
+ expect(trackSettingChanged).toHaveBeenCalledTimes(1)
+ expect(trackSettingChanged).toHaveBeenCalledWith(
+ expect.objectContaining({
+ setting_id: 'main.sub.setting.name',
+ previous_value: 'default',
+ new_value: 'normalized'
+ })
+ )
+ })
+
+ it('does not track telemetry when normalized value does not change', async () => {
+ const settingParams: SettingParams = {
+ id: 'main.sub.setting.name',
+ name: 'Telemetry Visible',
+ type: 'text',
+ defaultValue: 'same'
+ }
+
+ mockGet.mockReturnValueOnce('same').mockReturnValueOnce('same')
+ mockSet.mockResolvedValue(undefined)
+
+ const wrapper = mountComponent(settingParams)
+
+ const unchangedValue = 'same'
+ const formItem = wrapper.findComponent(FormItemUpdateStub)
+ formItem.vm.$emit('update:form-value', unchangedValue)
+
+ await flushPromises()
+
+ expect(trackSettingChanged).not.toHaveBeenCalled()
+ })
+})
diff --git a/tests-ui/tests/platform/telemetry/topupTracker.test.ts b/tests-ui/tests/platform/telemetry/topupTracker.test.ts
new file mode 100644
index 000000000..668ed2db7
--- /dev/null
+++ b/tests-ui/tests/platform/telemetry/topupTracker.test.ts
@@ -0,0 +1,207 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { AuditLog } from '@/services/customerEventsService'
+
+// Mock localStorage
+const mockLocalStorage = vi.hoisted(() => ({
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn()
+}))
+
+Object.defineProperty(window, 'localStorage', {
+ value: mockLocalStorage,
+ writable: true
+})
+
+// Mock telemetry
+const mockTelemetry = vi.hoisted(() => ({
+ trackApiCreditTopupSucceeded: vi.fn()
+}))
+
+vi.mock('@/platform/telemetry', () => ({
+ useTelemetry: vi.fn(() => mockTelemetry)
+}))
+
+describe('topupTracker', () => {
+ let topupTracker: typeof import('@/platform/telemetry/topupTracker')
+
+ 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()
+
+ topupTracker.startTopupTracking()
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
+ 'pending_topup_timestamp',
+ expect.any(String)
+ )
+
+ const savedTimestamp = parseInt(
+ mockLocalStorage.setItem.mock.calls[0][1],
+ 10
+ )
+ expect(savedTimestamp).toBeGreaterThanOrEqual(beforeTimestamp)
+ expect(savedTimestamp).toBeLessThanOrEqual(Date.now())
+ })
+ })
+
+ describe('checkForCompletedTopup', () => {
+ it('should return false if no pending topup exists', () => {
+ mockLocalStorage.getItem.mockReturnValue(null)
+
+ const result = topupTracker.checkForCompletedTopup([])
+
+ expect(result).toBe(false)
+ expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
+ })
+
+ it('should return false if events array is empty', () => {
+ mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
+
+ const result = topupTracker.checkForCompletedTopup([])
+
+ expect(result).toBe(false)
+ expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
+ })
+
+ it('should return false if events array is null', () => {
+ mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
+
+ const result = topupTracker.checkForCompletedTopup(null)
+
+ expect(result).toBe(false)
+ expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
+ })
+
+ it('should auto-cleanup if timestamp is older than 24 hours', () => {
+ const oldTimestamp = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago
+ mockLocalStorage.getItem.mockReturnValue(oldTimestamp.toString())
+
+ const events: AuditLog[] = [
+ {
+ event_id: 'test-1',
+ event_type: 'credit_added',
+ createdAt: new Date().toISOString(),
+ params: { amount: 500 }
+ }
+ ]
+
+ const result = topupTracker.checkForCompletedTopup(events)
+
+ expect(result).toBe(false)
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
+ 'pending_topup_timestamp'
+ )
+ expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
+ })
+
+ it('should detect completed topup and fire telemetry', () => {
+ const startTimestamp = Date.now() - 5 * 60 * 1000 // 5 minutes ago
+ mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
+
+ const events: AuditLog[] = [
+ {
+ event_id: 'test-1',
+ event_type: 'api_usage_completed',
+ createdAt: new Date(startTimestamp - 1000).toISOString(),
+ params: {}
+ },
+ {
+ event_id: 'test-2',
+ event_type: 'credit_added',
+ createdAt: new Date(startTimestamp + 1000).toISOString(),
+ params: { amount: 500 }
+ }
+ ]
+
+ const result = topupTracker.checkForCompletedTopup(events)
+
+ expect(result).toBe(true)
+ expect(mockTelemetry.trackApiCreditTopupSucceeded).toHaveBeenCalledOnce()
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
+ 'pending_topup_timestamp'
+ )
+ })
+
+ it('should not detect topup if credit_added event is before tracking started', () => {
+ const startTimestamp = Date.now()
+ mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
+
+ const events: AuditLog[] = [
+ {
+ event_id: 'test-1',
+ event_type: 'credit_added',
+ createdAt: new Date(startTimestamp - 1000).toISOString(), // Before tracking
+ params: { amount: 500 }
+ }
+ ]
+
+ const result = topupTracker.checkForCompletedTopup(events)
+
+ expect(result).toBe(false)
+ expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
+ expect(mockLocalStorage.removeItem).not.toHaveBeenCalled()
+ })
+
+ it('should ignore events without createdAt timestamp', () => {
+ const startTimestamp = Date.now()
+ mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
+
+ const events: AuditLog[] = [
+ {
+ event_id: 'test-1',
+ event_type: 'credit_added',
+ createdAt: undefined,
+ params: { amount: 500 }
+ }
+ ]
+
+ const result = topupTracker.checkForCompletedTopup(events)
+
+ expect(result).toBe(false)
+ expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
+ })
+
+ it('should only match credit_added events, not other event types', () => {
+ const startTimestamp = Date.now()
+ mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
+
+ const events: AuditLog[] = [
+ {
+ event_id: 'test-1',
+ event_type: 'api_usage_completed',
+ createdAt: new Date(startTimestamp + 1000).toISOString(),
+ params: {}
+ },
+ {
+ event_id: 'test-2',
+ event_type: 'account_created',
+ createdAt: new Date(startTimestamp + 2000).toISOString(),
+ params: {}
+ }
+ ]
+
+ const result = topupTracker.checkForCompletedTopup(events)
+
+ expect(result).toBe(false)
+ expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('clearTopupTracking', () => {
+ it('should remove pending topup from localStorage', () => {
+ topupTracker.clearTopupTracking()
+
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
+ 'pending_topup_timestamp'
+ )
+ })
+ })
+})
diff --git a/tests-ui/tests/platform/workflow/cloud/getWorkflowFromHistory.test.ts b/tests-ui/tests/platform/workflow/cloud/getWorkflowFromHistory.test.ts
new file mode 100644
index 000000000..fede0e864
--- /dev/null
+++ b/tests-ui/tests/platform/workflow/cloud/getWorkflowFromHistory.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
+import { getWorkflowFromHistory } from '@/platform/workflow/cloud/getWorkflowFromHistory'
+
+const mockWorkflow: ComfyWorkflowJSON = {
+ id: 'test-workflow-id',
+ revision: 0,
+ last_node_id: 5,
+ last_link_id: 3,
+ nodes: [],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4
+}
+
+const mockHistoryResponse = {
+ 'test-prompt-id': {
+ prompt: {
+ priority: 1,
+ prompt_id: 'test-prompt-id',
+ extra_data: {
+ client_id: 'test-client',
+ extra_pnginfo: {
+ workflow: mockWorkflow
+ }
+ }
+ },
+ outputs: {},
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ }
+ }
+}
+
+describe('getWorkflowFromHistory', () => {
+ it('should fetch workflow from /history_v2/{prompt_id} endpoint', async () => {
+ const mockFetchApi = vi.fn().mockResolvedValue({
+ json: async () => mockHistoryResponse
+ })
+
+ await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
+
+ expect(mockFetchApi).toHaveBeenCalledWith('/history_v2/test-prompt-id')
+ })
+
+ it('should extract and return workflow from response', async () => {
+ const mockFetchApi = vi.fn().mockResolvedValue({
+ json: async () => mockHistoryResponse
+ })
+
+ const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
+
+ expect(result).toEqual(mockWorkflow)
+ })
+
+ it('should return undefined when prompt_id not found in response', async () => {
+ const mockFetchApi = vi.fn().mockResolvedValue({
+ json: async () => ({})
+ })
+
+ const result = await getWorkflowFromHistory(mockFetchApi, 'nonexistent-id')
+
+ expect(result).toBeUndefined()
+ })
+
+ it('should return undefined when workflow is missing from extra_pnginfo', async () => {
+ const mockFetchApi = vi.fn().mockResolvedValue({
+ json: async () => ({
+ 'test-prompt-id': {
+ prompt: {
+ priority: 1,
+ prompt_id: 'test-prompt-id',
+ extra_data: {
+ client_id: 'test-client'
+ }
+ },
+ outputs: {}
+ }
+ })
+ })
+
+ const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
+
+ expect(result).toBeUndefined()
+ })
+
+ it('should handle fetch errors gracefully', async () => {
+ const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
+
+ const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
+
+ expect(result).toBeUndefined()
+ })
+
+ it('should handle malformed JSON responses', async () => {
+ const mockFetchApi = vi.fn().mockResolvedValue({
+ json: async () => {
+ throw new Error('Invalid JSON')
+ }
+ })
+
+ const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
+
+ expect(result).toBeUndefined()
+ })
+})
diff --git a/tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts b/tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts
new file mode 100644
index 000000000..5ed3b94ce
--- /dev/null
+++ b/tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts
@@ -0,0 +1,220 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
+
+/**
+ * Unit tests for useTemplateUrlLoader composable
+ *
+ * Tests the behavior of loading templates via URL query parameters:
+ * - ?template=flux_simple loads the template
+ * - ?template=flux_simple&source=custom loads from custom source
+ * - Invalid template shows error toast
+ * - Input validation for template and source parameters
+ */
+
+// Mock vue-router
+let mockQueryParams: Record = {}
+
+vi.mock('vue-router', () => ({
+ useRoute: vi.fn(() => ({
+ query: mockQueryParams
+ }))
+}))
+
+// Mock template workflows composable
+const mockLoadTemplates = vi.fn().mockResolvedValue(true)
+const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true)
+
+vi.mock(
+ '@/platform/workflow/templates/composables/useTemplateWorkflows',
+ () => ({
+ useTemplateWorkflows: () => ({
+ loadTemplates: mockLoadTemplates,
+ loadWorkflowTemplate: mockLoadWorkflowTemplate
+ })
+ })
+)
+
+// Mock toast
+const mockToastAdd = vi.fn()
+vi.mock('primevue/usetoast', () => ({
+ useToast: () => ({
+ add: mockToastAdd
+ })
+}))
+
+// Mock i18n
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: vi.fn((key: string, params?: any) => {
+ if (key === 'g.error') return 'Error'
+ if (key === 'templateWorkflows.error.templateNotFound') {
+ return `Template "${params?.templateName}" not found`
+ }
+ if (key === 'g.errorLoadingTemplate') return 'Failed to load template'
+ return key
+ })
+ })
+}))
+
+describe('useTemplateUrlLoader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockQueryParams = {}
+ })
+
+ it('does not load template when no query param present', () => {
+ mockQueryParams = {}
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ void loadTemplateFromUrl()
+
+ expect(mockLoadTemplates).not.toHaveBeenCalled()
+ expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
+ })
+
+ it('loads template when query param is present', async () => {
+ mockQueryParams = { template: 'flux_simple' }
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ await loadTemplateFromUrl()
+
+ expect(mockLoadTemplates).toHaveBeenCalledTimes(1)
+ expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
+ 'flux_simple',
+ 'default'
+ )
+ })
+
+ it('uses default source when source param is not provided', async () => {
+ mockQueryParams = { template: 'flux_simple' }
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ await loadTemplateFromUrl()
+
+ expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
+ 'flux_simple',
+ 'default'
+ )
+ })
+
+ it('uses custom source when source param is provided', async () => {
+ mockQueryParams = { template: 'custom-template', source: 'custom-module' }
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ await loadTemplateFromUrl()
+
+ expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
+ 'custom-template',
+ 'custom-module'
+ )
+ })
+
+ it('shows error toast when template loading fails', async () => {
+ mockQueryParams = { template: 'invalid-template' }
+ mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ await loadTemplateFromUrl()
+
+ expect(mockToastAdd).toHaveBeenCalledWith({
+ severity: 'error',
+ summary: 'Error',
+ detail: 'Template "invalid-template" not found',
+ life: 3000
+ })
+ })
+
+ it('handles array query params correctly', () => {
+ // Vue Router can return string[] for duplicate params
+ mockQueryParams = { template: ['first', 'second'] as any }
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ void loadTemplateFromUrl()
+
+ // Should not load when param is an array
+ expect(mockLoadTemplates).not.toHaveBeenCalled()
+ })
+
+ it('rejects invalid template parameter with special characters', () => {
+ // Test path traversal attempt
+ mockQueryParams = { template: '../../../etc/passwd' }
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ void loadTemplateFromUrl()
+
+ // Should not load invalid template
+ expect(mockLoadTemplates).not.toHaveBeenCalled()
+ })
+
+ it('rejects invalid template parameter with slash', () => {
+ mockQueryParams = { template: 'path/to/template' }
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ void loadTemplateFromUrl()
+
+ // Should not load invalid template
+ expect(mockLoadTemplates).not.toHaveBeenCalled()
+ })
+
+ it('accepts valid template parameter formats', async () => {
+ const validTemplates = [
+ 'flux_simple',
+ 'flux-kontext-dev',
+ 'template123',
+ 'My_Template-2'
+ ]
+
+ for (const template of validTemplates) {
+ vi.clearAllMocks()
+ mockQueryParams = { template }
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ await loadTemplateFromUrl()
+
+ expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(template, 'default')
+ }
+ })
+
+ it('rejects invalid source parameter with special characters', () => {
+ mockQueryParams = { template: 'flux_simple', source: '../malicious' }
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ void loadTemplateFromUrl()
+
+ // Should not load with invalid source
+ expect(mockLoadTemplates).not.toHaveBeenCalled()
+ })
+
+ it('accepts valid source parameter formats', async () => {
+ const validSources = ['default', 'custom-module', 'my_source', 'source123']
+
+ for (const source of validSources) {
+ vi.clearAllMocks()
+ mockQueryParams = { template: 'flux_simple', source }
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ await loadTemplateFromUrl()
+
+ expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
+ 'flux_simple',
+ source
+ )
+ }
+ })
+
+ it('shows error toast when exception is thrown', async () => {
+ mockQueryParams = { template: 'flux_simple' }
+ mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
+
+ const { loadTemplateFromUrl } = useTemplateUrlLoader()
+ await loadTemplateFromUrl()
+
+ expect(mockToastAdd).toHaveBeenCalledWith({
+ severity: 'error',
+ summary: 'Error',
+ detail: 'Failed to load template',
+ life: 3000
+ })
+ })
+})
diff --git a/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts
index 61e5577d8..f77a3a75e 100644
--- a/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts
+++ b/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts
@@ -208,10 +208,6 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
- // After clicking, component shows loading state (Skeleton), not img
- expect(wrapper.find('skeleton-stub').exists()).toBe(true)
- expect(wrapper.find('img').exists()).toBe(false)
-
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
diff --git a/tests-ui/tests/renderer/extensions/vueNodes/interactions/resize/resizeMath.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/interactions/resize/resizeMath.test.ts
index dd59534f1..d954e59b4 100644
--- a/tests-ui/tests/renderer/extensions/vueNodes/interactions/resize/resizeMath.test.ts
+++ b/tests-ui/tests/renderer/extensions/vueNodes/interactions/resize/resizeMath.test.ts
@@ -9,14 +9,12 @@ import {
describe('nodeResizeMath', () => {
const startSize = { width: 200, height: 120 }
const startPosition = { x: 80, y: 160 }
- const minSize = { width: 120, height: 80 }
it('computes resize from bottom-right corner without moving position', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: 40, y: 20 },
- minSize,
handle: { horizontal: 'right', vertical: 'bottom' }
})
@@ -29,7 +27,6 @@ describe('nodeResizeMath', () => {
startSize,
startPosition,
delta: { x: -30, y: -20 },
- minSize,
handle: { horizontal: 'left', vertical: 'top' }
})
@@ -37,27 +34,10 @@ describe('nodeResizeMath', () => {
expect(outcome.position).toEqual({ x: 50, y: 140 })
})
- it('clamps to minimum size when shrinking below intrinsic size', () => {
- const outcome = computeResizeOutcome({
- startSize,
- startPosition,
- delta: { x: 500, y: 500 },
- minSize,
- handle: { horizontal: 'left', vertical: 'top' }
- })
-
- expect(outcome.size).toEqual(minSize)
- expect(outcome.position).toEqual({
- x: startPosition.x + (startSize.width - minSize.width),
- y: startPosition.y + (startSize.height - minSize.height)
- })
- })
-
it('supports reusable resize sessions with snapping', () => {
const session = createResizeSession({
startSize,
startPosition,
- minSize,
handle: { horizontal: 'right', vertical: 'bottom' }
})
@@ -91,7 +71,6 @@ describe('nodeResizeMath', () => {
startSize,
startPosition,
delta: { x: -50, y: -30 },
- minSize,
handle: { horizontal: 'right', vertical: 'bottom' }
})
@@ -104,7 +83,6 @@ describe('nodeResizeMath', () => {
startSize,
startPosition,
delta: { x: 10000, y: 10000 },
- minSize,
handle: { horizontal: 'right', vertical: 'bottom' }
})
@@ -112,49 +90,5 @@ describe('nodeResizeMath', () => {
expect(outcome.size.height).toBe(10120)
expect(outcome.position).toEqual(startPosition)
})
-
- it('respects minimum size even with extreme negative deltas', () => {
- const outcome = computeResizeOutcome({
- startSize,
- startPosition,
- delta: { x: -1000, y: -1000 },
- minSize,
- handle: { horizontal: 'right', vertical: 'bottom' }
- })
-
- expect(outcome.size).toEqual(minSize)
- expect(outcome.position).toEqual(startPosition)
- })
-
- it('handles minSize larger than startSize', () => {
- const largeMinSize = { width: 300, height: 200 }
- const outcome = computeResizeOutcome({
- startSize,
- startPosition,
- delta: { x: 10, y: 10 },
- minSize: largeMinSize,
- handle: { horizontal: 'right', vertical: 'bottom' }
- })
-
- expect(outcome.size).toEqual(largeMinSize)
- expect(outcome.position).toEqual(startPosition)
- })
-
- it('adjusts position correctly when minSize prevents shrinking from top-left', () => {
- const largeMinSize = { width: 250, height: 150 }
- const outcome = computeResizeOutcome({
- startSize,
- startPosition,
- delta: { x: 100, y: 100 },
- minSize: largeMinSize,
- handle: { horizontal: 'left', vertical: 'top' }
- })
-
- expect(outcome.size).toEqual(largeMinSize)
- expect(outcome.position).toEqual({
- x: startPosition.x + (startSize.width - largeMinSize.width),
- y: startPosition.y + (startSize.height - largeMinSize.height)
- })
- })
})
})
diff --git a/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
index 3bb985a47..54a101335 100644
--- a/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
+++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
@@ -4,13 +4,61 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { assetService } from '@/platform/assets/services/assetService'
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
+// Mock factory using actual type
+function createMockAssetItem(overrides: Partial = {}): AssetItem {
+ return {
+ id: 'test-asset-id',
+ name: 'test-image.png',
+ asset_hash: 'hash123',
+ size: 1024,
+ mime_type: 'image/png',
+ tags: ['input'],
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ last_access_time: new Date().toISOString(),
+ ...overrides
+ }
+}
+
+// Use vi.hoisted() to ensure mock state is initialized before mocks
+const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
+const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
+const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
+const mockAssetsStoreState = vi.hoisted(() => {
+ const inputAssets: AssetItem[] = []
+ return {
+ inputAssets,
+ inputLoading: false
+ }
+})
+
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
+vi.mock('@/platform/distribution/types', () => ({
+ get isCloud() {
+ return mockDistributionState.isCloud
+ }
+}))
+
+vi.mock('@/stores/assetsStore', () => ({
+ useAssetsStore: vi.fn(() => ({
+ get inputAssets() {
+ return mockAssetsStoreState.inputAssets
+ },
+ get inputLoading() {
+ return mockAssetsStoreState.inputLoading
+ },
+ updateInputs: mockUpdateInputs,
+ getInputName: mockGetInputName
+ }))
+}))
+
const mockSettingStoreGet = vi.fn(() => false)
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
@@ -42,7 +90,7 @@ vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
// Test factory functions
function createMockWidget(overrides: Partial = {}): IBaseWidget {
const mockCallback = vi.fn()
- return {
+ const widget: IBaseWidget = {
type: 'combo',
options: {},
name: 'testWidget',
@@ -50,7 +98,8 @@ function createMockWidget(overrides: Partial = {}): IBaseWidget {
callback: mockCallback,
y: 0,
...overrides
- } as IBaseWidget
+ }
+ return widget
}
function createMockNode(comfyClass = 'TestNode'): LGraphNode {
@@ -73,11 +122,12 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode {
}
function createMockInputSpec(overrides: Partial = {}): InputSpec {
- return {
+ const inputSpec: InputSpec = {
type: 'COMBO',
name: 'testInput',
...overrides
- } as InputSpec
+ }
+ return inputSpec
}
describe('useComboWidget', () => {
@@ -86,6 +136,10 @@ describe('useComboWidget', () => {
mockSettingStoreGet.mockReturnValue(false)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
vi.mocked(useAssetBrowserDialog).mockClear()
+ mockDistributionState.isCloud = false
+ mockAssetsStoreState.inputAssets = []
+ mockAssetsStoreState.inputLoading = false
+ mockUpdateInputs.mockClear()
})
it('should handle undefined spec', () => {
@@ -110,6 +164,7 @@ describe('useComboWidget', () => {
})
it('should create normal combo widget when asset API is disabled', () => {
+ mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(false) // Asset API disabled
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) // Widget is eligible
@@ -137,6 +192,7 @@ describe('useComboWidget', () => {
})
it('should create asset browser widget when API enabled', () => {
+ mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
@@ -169,36 +225,8 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
- it('should create asset browser widget with options when API enabled', () => {
- mockSettingStoreGet.mockReturnValue(true)
- vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
-
- const constructor = useComboWidget()
- const mockWidget = createMockWidget({
- type: 'asset',
- name: 'ckpt_name',
- value: 'model1.safetensors'
- })
- const mockNode = createMockNode('CheckpointLoaderSimple')
- vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
- const inputSpec = createMockInputSpec({
- name: 'ckpt_name',
- options: ['model1.safetensors', 'model2.safetensors']
- })
-
- const widget = constructor(mockNode, inputSpec)
-
- expect(mockNode.addWidget).toHaveBeenCalledWith(
- 'asset',
- 'ckpt_name',
- 'model1.safetensors',
- expect.any(Function)
- )
- expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
- expect(widget).toBe(mockWidget)
- })
-
- it('should use asset browser widget even when inputSpec has a default value but no options', () => {
+ it('should create asset browser widget when default value provided without options', () => {
+ mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
@@ -229,6 +257,7 @@ describe('useComboWidget', () => {
})
it('should show Select model when asset widget has undefined current value', () => {
+ mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
@@ -256,4 +285,203 @@ describe('useComboWidget', () => {
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)
})
+
+ describe('cloud input asset mapping', () => {
+ const HASH_FILENAME =
+ '72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
+ const HASH_FILENAME_2 =
+ 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
+
+ it.each([
+ { nodeClass: 'LoadImage', inputName: 'image' },
+ { nodeClass: 'LoadVideo', inputName: 'video' },
+ { nodeClass: 'LoadAudio', inputName: 'audio' }
+ ])(
+ 'should create combo widget with getOptionLabel for $nodeClass in cloud',
+ ({ nodeClass, inputName }) => {
+ mockDistributionState.isCloud = true
+
+ const constructor = useComboWidget()
+ const mockWidget = createMockWidget({
+ type: 'combo',
+ name: inputName,
+ value: HASH_FILENAME
+ })
+ const mockNode = createMockNode(nodeClass)
+ vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
+ const inputSpec = createMockInputSpec({
+ name: inputName,
+ options: [HASH_FILENAME, HASH_FILENAME_2]
+ })
+
+ const widget = constructor(mockNode, inputSpec)
+
+ expect(mockNode.addWidget).toHaveBeenCalledWith(
+ 'combo',
+ inputName,
+ HASH_FILENAME,
+ expect.any(Function),
+ expect.objectContaining({
+ values: [], // Empty initially, populated dynamically by Proxy
+ getOptionLabel: expect.any(Function)
+ })
+ )
+ expect(widget).toBe(mockWidget)
+ }
+ )
+
+ it("should format option labels using store's getInputName function", () => {
+ mockDistributionState.isCloud = true
+ mockGetInputName.mockReturnValue('Beautiful Sunset.png')
+
+ const constructor = useComboWidget()
+ const mockWidget = createMockWidget({
+ type: 'combo',
+ name: 'image',
+ value: HASH_FILENAME
+ })
+ const mockNode = createMockNode('LoadImage')
+ vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
+ const inputSpec = createMockInputSpec({
+ name: 'image',
+ options: [HASH_FILENAME]
+ })
+
+ constructor(mockNode, inputSpec)
+
+ // Extract the injected getOptionLabel function with type narrowing
+ const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0]
+ const options = addWidgetCall[4]
+
+ if (typeof options !== 'object' || !options) {
+ throw new Error('Expected options to be an object')
+ }
+
+ if (!('getOptionLabel' in options)) {
+ throw new Error('Expected options to have getOptionLabel property')
+ }
+
+ if (typeof options.getOptionLabel !== 'function') {
+ throw new Error('Expected getOptionLabel to be a function')
+ }
+
+ // Test that the injected function calls getInputName
+ const result = options.getOptionLabel(HASH_FILENAME)
+ expect(mockGetInputName).toHaveBeenCalledWith(HASH_FILENAME)
+ expect(result).toBe('Beautiful Sunset.png')
+ })
+
+ it('should create normal combo widget for non-input nodes in cloud', () => {
+ mockDistributionState.isCloud = true
+
+ const constructor = useComboWidget()
+ const mockWidget = createMockWidget()
+ const mockNode = createMockNode('SomeOtherNode')
+ vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
+ const inputSpec = createMockInputSpec({
+ name: 'option',
+ options: [HASH_FILENAME, HASH_FILENAME_2]
+ })
+
+ const widget = constructor(mockNode, inputSpec)
+
+ expect(mockNode.addWidget).toHaveBeenCalledWith(
+ 'combo',
+ 'option',
+ HASH_FILENAME,
+ expect.any(Function),
+ { values: [HASH_FILENAME, HASH_FILENAME_2] }
+ )
+ expect(widget).toBe(mockWidget)
+ })
+
+ it('should create normal combo widget for LoadImage in OSS', () => {
+ mockDistributionState.isCloud = false
+
+ const constructor = useComboWidget()
+ const mockWidget = createMockWidget()
+ const mockNode = createMockNode('LoadImage')
+ vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
+ const inputSpec = createMockInputSpec({
+ name: 'image',
+ options: [HASH_FILENAME, HASH_FILENAME_2]
+ })
+
+ const widget = constructor(mockNode, inputSpec)
+
+ expect(mockNode.addWidget).toHaveBeenCalledWith(
+ 'combo',
+ 'image',
+ HASH_FILENAME,
+ expect.any(Function),
+ {
+ values: [HASH_FILENAME, HASH_FILENAME_2]
+ }
+ )
+ expect(widget).toBe(mockWidget)
+ })
+
+ it('should trigger lazy load for cloud input nodes', () => {
+ mockDistributionState.isCloud = true
+ mockAssetsStoreState.inputAssets = []
+ mockAssetsStoreState.inputLoading = false
+
+ const constructor = useComboWidget()
+ const mockWidget = createMockWidget({ type: 'combo' })
+ const mockNode = createMockNode('LoadImage')
+ vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
+ const inputSpec = createMockInputSpec({
+ name: 'image',
+ options: [HASH_FILENAME]
+ })
+
+ constructor(mockNode, inputSpec)
+
+ expect(mockUpdateInputs).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not trigger lazy load if assets already loading', () => {
+ mockDistributionState.isCloud = true
+ mockAssetsStoreState.inputAssets = []
+ mockAssetsStoreState.inputLoading = true
+
+ const constructor = useComboWidget()
+ const mockWidget = createMockWidget({ type: 'combo' })
+ const mockNode = createMockNode('LoadImage')
+ vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
+ const inputSpec = createMockInputSpec({
+ name: 'image',
+ options: [HASH_FILENAME]
+ })
+
+ constructor(mockNode, inputSpec)
+
+ expect(mockUpdateInputs).not.toHaveBeenCalled()
+ })
+
+ it('should not trigger lazy load if assets already loaded', () => {
+ mockDistributionState.isCloud = true
+ mockAssetsStoreState.inputAssets = [
+ createMockAssetItem({
+ id: 'asset-123',
+ name: 'image1.png',
+ asset_hash: HASH_FILENAME
+ })
+ ]
+ mockAssetsStoreState.inputLoading = false
+
+ const constructor = useComboWidget()
+ const mockWidget = createMockWidget({ type: 'combo' })
+ const mockNode = createMockNode('LoadImage')
+ vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
+ const inputSpec = createMockInputSpec({
+ name: 'image',
+ options: [HASH_FILENAME]
+ })
+
+ constructor(mockNode, inputSpec)
+
+ expect(mockUpdateInputs).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/tests-ui/tests/services/assetService.test.ts b/tests-ui/tests/services/assetService.test.ts
index e9b757cf0..ead7902ff 100644
--- a/tests-ui/tests/services/assetService.test.ts
+++ b/tests-ui/tests/services/assetService.test.ts
@@ -294,7 +294,7 @@ describe('assetService', () => {
const result = await assetService.getAssetsByTag('models')
expect(api.fetchApi).toHaveBeenCalledWith(
- '/assets?include_tags=models&limit=500'
+ '/assets?include_tags=models&limit=500&include_public=true'
)
expect(result).toEqual(testAssets)
})
@@ -352,5 +352,72 @@ describe('assetService', () => {
expect(result[0]).toHaveProperty('asset_hash', 'blake3:full123')
expect(result[0]).toHaveProperty('user_metadata')
})
+
+ it('should exclude public assets when includePublic is false', async () => {
+ const testAssets = [MOCK_ASSETS.checkpoints]
+ mockApiResponse(testAssets)
+
+ const result = await assetService.getAssetsByTag('input', false)
+
+ expect(api.fetchApi).toHaveBeenCalledWith(
+ '/assets?include_tags=input&limit=500&include_public=false'
+ )
+ expect(result).toEqual(testAssets)
+ })
+
+ it('should include public assets when includePublic is true', async () => {
+ const testAssets = [MOCK_ASSETS.checkpoints, MOCK_ASSETS.loras]
+ mockApiResponse(testAssets)
+
+ const result = await assetService.getAssetsByTag('models', true)
+
+ expect(api.fetchApi).toHaveBeenCalledWith(
+ '/assets?include_tags=models&limit=500&include_public=true'
+ )
+ expect(result).toEqual(testAssets)
+ })
+
+ it('should accept custom limit via options', async () => {
+ const testAssets = [MOCK_ASSETS.checkpoints]
+ mockApiResponse(testAssets)
+
+ const result = await assetService.getAssetsByTag('input', false, {
+ limit: 100
+ })
+
+ expect(api.fetchApi).toHaveBeenCalledWith(
+ '/assets?include_tags=input&limit=100&include_public=false'
+ )
+ expect(result).toEqual(testAssets)
+ })
+
+ it('should accept custom offset via options', async () => {
+ const testAssets = [MOCK_ASSETS.loras]
+ mockApiResponse(testAssets)
+
+ const result = await assetService.getAssetsByTag('models', true, {
+ offset: 50
+ })
+
+ expect(api.fetchApi).toHaveBeenCalledWith(
+ '/assets?include_tags=models&limit=500&include_public=true&offset=50'
+ )
+ expect(result).toEqual(testAssets)
+ })
+
+ it('should accept both limit and offset via options', async () => {
+ const testAssets = [MOCK_ASSETS.checkpoints]
+ mockApiResponse(testAssets)
+
+ const result = await assetService.getAssetsByTag('input', false, {
+ limit: 100,
+ offset: 25
+ })
+
+ expect(api.fetchApi).toHaveBeenCalledWith(
+ '/assets?include_tags=input&limit=100&include_public=false&offset=25'
+ )
+ expect(result).toEqual(testAssets)
+ })
})
})
diff --git a/tests-ui/tests/store/assetsStore.test.ts b/tests-ui/tests/store/assetsStore.test.ts
new file mode 100644
index 000000000..300efe02f
--- /dev/null
+++ b/tests-ui/tests/store/assetsStore.test.ts
@@ -0,0 +1,157 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { useAssetsStore } from '@/stores/assetsStore'
+
+const HASH_FILENAME =
+ '72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
+const HASH_FILENAME_2 =
+ 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
+
+function createMockAssetItem(overrides: Partial = {}): AssetItem {
+ return {
+ id: 'test-id',
+ name: 'test.png',
+ asset_hash: 'test-hash',
+ size: 1024,
+ tags: [],
+ created_at: '2024-01-01T00:00:00.000Z',
+ ...overrides
+ }
+}
+
+describe('assetsStore', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ })
+
+ describe('input asset mapping helpers', () => {
+ it('should return name for valid asset_hash', () => {
+ const store = useAssetsStore()
+
+ store.inputAssets = [
+ createMockAssetItem({
+ name: 'Beautiful Sunset.png',
+ asset_hash: HASH_FILENAME
+ }),
+ createMockAssetItem({
+ name: 'Mountain Vista.jpg',
+ asset_hash: HASH_FILENAME_2
+ })
+ ]
+
+ expect(store.getInputName(HASH_FILENAME)).toBe('Beautiful Sunset.png')
+ expect(store.getInputName(HASH_FILENAME_2)).toBe('Mountain Vista.jpg')
+ })
+
+ it('should return original hash when no matching asset found', () => {
+ const store = useAssetsStore()
+
+ store.inputAssets = [
+ createMockAssetItem({
+ name: 'Beautiful Sunset.png',
+ asset_hash: HASH_FILENAME
+ })
+ ]
+
+ const unknownHash =
+ 'fffffffffffffffffffffffffffuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu.png'
+ expect(store.getInputName(unknownHash)).toBe(unknownHash)
+ })
+
+ it('should return hash as-is when no assets loaded', () => {
+ const store = useAssetsStore()
+
+ store.inputAssets = []
+
+ expect(store.getInputName(HASH_FILENAME)).toBe(HASH_FILENAME)
+ })
+
+ it('should ignore assets without asset_hash', () => {
+ const store = useAssetsStore()
+
+ store.inputAssets = [
+ createMockAssetItem({
+ name: 'Beautiful Sunset.png',
+ asset_hash: HASH_FILENAME
+ }),
+ createMockAssetItem({
+ name: 'No Hash Asset.jpg',
+ asset_hash: null
+ })
+ ]
+
+ // Should find first asset
+ expect(store.getInputName(HASH_FILENAME)).toBe('Beautiful Sunset.png')
+ // Map should only contain one entry
+ expect(store.inputAssetsByFilename.size).toBe(1)
+ })
+ })
+
+ describe('inputAssetsByFilename computed', () => {
+ it('should create map keyed by asset_hash', () => {
+ const store = useAssetsStore()
+
+ store.inputAssets = [
+ createMockAssetItem({
+ id: 'asset-123',
+ name: 'Beautiful Sunset.png',
+ asset_hash: HASH_FILENAME
+ }),
+ createMockAssetItem({
+ id: 'asset-456',
+ name: 'Mountain Vista.jpg',
+ asset_hash: HASH_FILENAME_2
+ })
+ ]
+
+ const map = store.inputAssetsByFilename
+
+ expect(map.size).toBe(2)
+ expect(map.get(HASH_FILENAME)).toMatchObject({
+ id: 'asset-123',
+ name: 'Beautiful Sunset.png',
+ asset_hash: HASH_FILENAME
+ })
+ expect(map.get(HASH_FILENAME_2)).toMatchObject({
+ id: 'asset-456',
+ name: 'Mountain Vista.jpg',
+ asset_hash: HASH_FILENAME_2
+ })
+ })
+
+ it('should exclude assets with null/undefined hash from map', () => {
+ const store = useAssetsStore()
+
+ store.inputAssets = [
+ createMockAssetItem({
+ name: 'Has Hash.png',
+ asset_hash: HASH_FILENAME
+ }),
+ createMockAssetItem({
+ name: 'Null Hash.jpg',
+ asset_hash: null
+ }),
+ createMockAssetItem({
+ name: 'Undefined Hash.jpg',
+ asset_hash: undefined
+ })
+ ]
+
+ const map = store.inputAssetsByFilename
+
+ // Only asset with valid asset_hash should be in map
+ expect(map.size).toBe(1)
+ expect(map.has(HASH_FILENAME)).toBe(true)
+ })
+
+ it('should return empty map when no assets loaded', () => {
+ const store = useAssetsStore()
+
+ store.inputAssets = []
+
+ expect(store.inputAssetsByFilename.size).toBe(0)
+ })
+ })
+})
diff --git a/tests-ui/tests/store/executionStore.test.ts b/tests-ui/tests/store/executionStore.test.ts
index 8632c4922..5b7c934ab 100644
--- a/tests-ui/tests/store/executionStore.test.ts
+++ b/tests-ui/tests/store/executionStore.test.ts
@@ -156,14 +156,11 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
expect(result).toBe('123')
})
- it('should return null when conversion fails', () => {
+ it('should return undefined when conversion fails', () => {
// Mock app.graph.getNodeById to return null (node not found)
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
- // This should throw an error as the node is not found
- expect(() => store.executionIdToNodeLocatorId('999:456')).toThrow(
- 'Subgraph not found: 999'
- )
+ expect(store.executionIdToNodeLocatorId('999:456')).toBe(undefined)
})
})
diff --git a/tests-ui/tests/store/queueStore.test.ts b/tests-ui/tests/store/queueStore.test.ts
index 313673e69..e01ec5afd 100644
--- a/tests-ui/tests/store/queueStore.test.ts
+++ b/tests-ui/tests/store/queueStore.test.ts
@@ -1,6 +1,85 @@
-import { describe, expect, it } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { TaskItemImpl } from '@/stores/queueStore'
+import type {
+ HistoryTaskItem,
+ PendingTaskItem,
+ RunningTaskItem,
+ TaskOutput,
+ TaskPrompt,
+ TaskStatus
+} from '@/schemas/apiSchema'
+import { api } from '@/scripts/api'
+import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
+
+// Fixture factories
+const createTaskPrompt = (
+ queueIndex: number,
+ promptId: string,
+ inputs: Record = {},
+ extraData: Record = {},
+ outputsToExecute: any[] = []
+): TaskPrompt => [queueIndex, promptId, inputs, extraData, outputsToExecute]
+
+const createTaskStatus = (
+ statusStr: 'success' | 'error' = 'success',
+ messages: any[] = []
+): TaskStatus => ({
+ status_str: statusStr,
+ completed: true,
+ messages
+})
+
+const createTaskOutput = (
+ nodeId: string = 'node-1',
+ images: any[] = []
+): TaskOutput => ({
+ [nodeId]: {
+ images
+ }
+})
+
+const createRunningTask = (
+ queueIndex: number,
+ promptId: string
+): RunningTaskItem => ({
+ taskType: 'Running',
+ prompt: createTaskPrompt(queueIndex, promptId),
+ remove: { name: 'Cancel', cb: () => {} }
+})
+
+const createPendingTask = (
+ queueIndex: number,
+ promptId: string
+): PendingTaskItem => ({
+ taskType: 'Pending',
+ prompt: createTaskPrompt(queueIndex, promptId)
+})
+
+const createHistoryTask = (
+ queueIndex: number,
+ promptId: string,
+ outputs: TaskOutput = createTaskOutput(),
+ status: TaskStatus = createTaskStatus()
+): HistoryTaskItem => ({
+ taskType: 'History',
+ prompt: createTaskPrompt(queueIndex, promptId),
+ status,
+ outputs
+})
+
+// Mock API
+vi.mock('@/scripts/api', () => ({
+ api: {
+ getQueue: vi.fn(),
+ getHistory: vi.fn(),
+ clearItems: vi.fn(),
+ deleteItem: vi.fn(),
+ apiURL: vi.fn((path) => `/api${path}`),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn()
+ }
+}))
describe('TaskItemImpl', () => {
it('should remove animated property from outputs during construction', () => {
@@ -154,3 +233,541 @@ describe('TaskItemImpl', () => {
})
})
})
+
+describe('useQueueStore', () => {
+ let store: ReturnType
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ store = useQueueStore()
+ vi.clearAllMocks()
+ })
+
+ const mockGetQueue = vi.mocked(api.getQueue)
+ const mockGetHistory = vi.mocked(api.getHistory)
+ const mockClearItems = vi.mocked(api.clearItems)
+ const mockDeleteItem = vi.mocked(api.deleteItem)
+
+ describe('initial state', () => {
+ it('should have empty state on initialization', () => {
+ expect(store.runningTasks).toEqual([])
+ expect(store.pendingTasks).toEqual([])
+ expect(store.historyTasks).toEqual([])
+ expect(store.isLoading).toBe(false)
+ expect(store.maxHistoryItems).toBe(64)
+ })
+
+ it('should have empty computed tasks', () => {
+ expect(store.tasks).toEqual([])
+ expect(store.flatTasks).toEqual([])
+ expect(store.hasPendingTasks).toBe(false)
+ expect(store.lastHistoryQueueIndex).toBe(-1)
+ })
+ })
+
+ describe('update() - basic functionality', () => {
+ it('should load running and pending tasks from API', async () => {
+ const runningTask = createRunningTask(1, 'run-1')
+ const pendingTask1 = createPendingTask(2, 'pend-1')
+ const pendingTask2 = createPendingTask(3, 'pend-2')
+
+ mockGetQueue.mockResolvedValue({
+ Running: [runningTask],
+ Pending: [pendingTask1, pendingTask2]
+ })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await store.update()
+
+ expect(store.runningTasks).toHaveLength(1)
+ expect(store.pendingTasks).toHaveLength(2)
+ expect(store.runningTasks[0].promptId).toBe('run-1')
+ expect(store.pendingTasks[0].promptId).toBe('pend-2')
+ expect(store.pendingTasks[1].promptId).toBe('pend-1')
+ })
+
+ it('should load history tasks from API', async () => {
+ const historyTask1 = createHistoryTask(5, 'hist-1')
+ const historyTask2 = createHistoryTask(4, 'hist-2')
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({
+ History: [historyTask1, historyTask2]
+ })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(2)
+ expect(store.historyTasks[0].promptId).toBe('hist-1')
+ expect(store.historyTasks[1].promptId).toBe('hist-2')
+ })
+
+ it('should set loading state correctly', async () => {
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ expect(store.isLoading).toBe(false)
+
+ const updatePromise = store.update()
+ expect(store.isLoading).toBe(true)
+
+ await updatePromise
+ expect(store.isLoading).toBe(false)
+ })
+
+ it('should clear loading state even if API fails', async () => {
+ mockGetQueue.mockRejectedValue(new Error('API error'))
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await expect(store.update()).rejects.toThrow('API error')
+ expect(store.isLoading).toBe(false)
+ })
+ })
+
+ describe('update() - sorting', () => {
+ it('should sort tasks by queueIndex descending', async () => {
+ const task1 = createHistoryTask(1, 'hist-1')
+ const task2 = createHistoryTask(5, 'hist-2')
+ const task3 = createHistoryTask(3, 'hist-3')
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({
+ History: [task1, task2, task3]
+ })
+
+ await store.update()
+
+ expect(store.historyTasks[0].queueIndex).toBe(5)
+ expect(store.historyTasks[1].queueIndex).toBe(3)
+ expect(store.historyTasks[2].queueIndex).toBe(1)
+ })
+
+ it('should sort pending tasks by queueIndex descending', async () => {
+ const pend1 = createPendingTask(10, 'pend-1')
+ const pend2 = createPendingTask(15, 'pend-2')
+ const pend3 = createPendingTask(12, 'pend-3')
+
+ mockGetQueue.mockResolvedValue({
+ Running: [],
+ Pending: [pend1, pend2, pend3]
+ })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await store.update()
+
+ expect(store.pendingTasks[0].queueIndex).toBe(15)
+ expect(store.pendingTasks[1].queueIndex).toBe(12)
+ expect(store.pendingTasks[2].queueIndex).toBe(10)
+ })
+ })
+
+ describe('update() - queue index collision (THE BUG FIX)', () => {
+ it('should NOT confuse different prompts with same queueIndex', async () => {
+ const hist1 = createHistoryTask(50, 'prompt-uuid-aaa')
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [hist1] })
+
+ await store.update()
+ expect(store.historyTasks).toHaveLength(1)
+ expect(store.historyTasks[0].promptId).toBe('prompt-uuid-aaa')
+
+ const hist2 = createHistoryTask(51, 'prompt-uuid-bbb')
+ mockGetHistory.mockResolvedValue({
+ History: [hist2]
+ })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(1)
+ expect(store.historyTasks[0].promptId).toBe('prompt-uuid-bbb')
+ expect(store.historyTasks[0].queueIndex).toBe(51)
+ })
+
+ it('should correctly reconcile when queueIndex is reused', async () => {
+ const hist1 = createHistoryTask(100, 'first-prompt-at-100')
+ const hist2 = createHistoryTask(99, 'prompt-at-99')
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [hist1, hist2] })
+
+ await store.update()
+ expect(store.historyTasks).toHaveLength(2)
+
+ const hist3 = createHistoryTask(101, 'second-prompt-at-101')
+ mockGetHistory.mockResolvedValue({
+ History: [hist3, hist2]
+ })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(2)
+ const promptIds = store.historyTasks.map((t) => t.promptId)
+ expect(promptIds).toContain('second-prompt-at-101')
+ expect(promptIds).toContain('prompt-at-99')
+ expect(promptIds).not.toContain('first-prompt-at-100')
+ })
+
+ it('should handle multiple queueIndex collisions simultaneously', async () => {
+ const hist1 = createHistoryTask(10, 'old-at-10')
+ const hist2 = createHistoryTask(20, 'old-at-20')
+ const hist3 = createHistoryTask(30, 'keep-at-30')
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({
+ History: [hist3, hist2, hist1]
+ })
+
+ await store.update()
+ expect(store.historyTasks).toHaveLength(3)
+
+ const newHist1 = createHistoryTask(31, 'new-at-31')
+ const newHist2 = createHistoryTask(32, 'new-at-32')
+ mockGetHistory.mockResolvedValue({
+ History: [newHist2, newHist1, hist3]
+ })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(3)
+ const promptIds = store.historyTasks.map((t) => t.promptId)
+ expect(promptIds).toEqual(['new-at-32', 'new-at-31', 'keep-at-30'])
+ })
+ })
+
+ describe('update() - history reconciliation', () => {
+ it('should keep existing items still on server (by promptId)', async () => {
+ const hist1 = createHistoryTask(10, 'existing-1')
+ const hist2 = createHistoryTask(9, 'existing-2')
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [hist1, hist2] })
+
+ await store.update()
+ expect(store.historyTasks).toHaveLength(2)
+
+ const hist3 = createHistoryTask(11, 'new-1')
+ mockGetHistory.mockResolvedValue({
+ History: [hist3, hist1, hist2]
+ })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(3)
+ expect(store.historyTasks.map((t) => t.promptId)).toContain('existing-1')
+ expect(store.historyTasks.map((t) => t.promptId)).toContain('existing-2')
+ expect(store.historyTasks.map((t) => t.promptId)).toContain('new-1')
+ })
+
+ it('should remove items no longer on server', async () => {
+ const hist1 = createHistoryTask(10, 'remove-me')
+ const hist2 = createHistoryTask(9, 'keep-me')
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [hist1, hist2] })
+
+ await store.update()
+ expect(store.historyTasks).toHaveLength(2)
+
+ mockGetHistory.mockResolvedValue({ History: [hist2] })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(1)
+ expect(store.historyTasks[0].promptId).toBe('keep-me')
+ })
+
+ it('should add new items from server', async () => {
+ const hist1 = createHistoryTask(5, 'old-1')
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [hist1] })
+
+ await store.update()
+
+ const hist2 = createHistoryTask(6, 'new-1')
+ const hist3 = createHistoryTask(7, 'new-2')
+ mockGetHistory.mockResolvedValue({
+ History: [hist3, hist2, hist1]
+ })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(3)
+ expect(store.historyTasks.map((t) => t.promptId)).toContain('new-1')
+ expect(store.historyTasks.map((t) => t.promptId)).toContain('new-2')
+ })
+ })
+
+ describe('update() - maxHistoryItems limit', () => {
+ it('should enforce maxHistoryItems limit', async () => {
+ store.maxHistoryItems = 3
+
+ const tasks = Array.from({ length: 5 }, (_, i) =>
+ createHistoryTask(10 - i, `hist-${i}`)
+ )
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: tasks })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(3)
+ expect(store.historyTasks[0].queueIndex).toBe(10)
+ expect(store.historyTasks[1].queueIndex).toBe(9)
+ expect(store.historyTasks[2].queueIndex).toBe(8)
+ })
+
+ it('should respect maxHistoryItems when combining new and existing', async () => {
+ store.maxHistoryItems = 5
+
+ const initial = Array.from({ length: 3 }, (_, i) =>
+ createHistoryTask(10 + i, `existing-${i}`)
+ )
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: initial })
+
+ await store.update()
+ expect(store.historyTasks).toHaveLength(3)
+
+ const newTasks = Array.from({ length: 4 }, (_, i) =>
+ createHistoryTask(20 + i, `new-${i}`)
+ )
+ mockGetHistory.mockResolvedValue({
+ History: [...newTasks, ...initial]
+ })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(5)
+ expect(store.historyTasks[0].queueIndex).toBe(23)
+ })
+
+ it('should handle maxHistoryItems = 0', async () => {
+ store.maxHistoryItems = 0
+
+ const tasks = [createHistoryTask(10, 'hist-1')]
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: tasks })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(0)
+ })
+
+ it('should handle maxHistoryItems = 1', async () => {
+ store.maxHistoryItems = 1
+
+ const tasks = [
+ createHistoryTask(10, 'hist-1'),
+ createHistoryTask(9, 'hist-2')
+ ]
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: tasks })
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(1)
+ expect(store.historyTasks[0].queueIndex).toBe(10)
+ })
+
+ it('should dynamically adjust when maxHistoryItems changes', async () => {
+ store.maxHistoryItems = 10
+
+ const tasks = Array.from({ length: 15 }, (_, i) =>
+ createHistoryTask(20 - i, `hist-${i}`)
+ )
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: tasks })
+
+ await store.update()
+ expect(store.historyTasks).toHaveLength(10)
+
+ store.maxHistoryItems = 5
+ mockGetHistory.mockResolvedValue({ History: tasks })
+
+ await store.update()
+ expect(store.historyTasks).toHaveLength(5)
+ })
+ })
+
+ describe('computed properties', () => {
+ it('tasks should combine pending, running, and history in correct order', async () => {
+ const running = createRunningTask(5, 'run-1')
+ const pending1 = createPendingTask(6, 'pend-1')
+ const pending2 = createPendingTask(7, 'pend-2')
+ const hist1 = createHistoryTask(3, 'hist-1')
+ const hist2 = createHistoryTask(4, 'hist-2')
+
+ mockGetQueue.mockResolvedValue({
+ Running: [running],
+ Pending: [pending1, pending2]
+ })
+ mockGetHistory.mockResolvedValue({
+ History: [hist2, hist1]
+ })
+
+ await store.update()
+
+ expect(store.tasks).toHaveLength(5)
+ expect(store.tasks[0].taskType).toBe('Pending')
+ expect(store.tasks[1].taskType).toBe('Pending')
+ expect(store.tasks[2].taskType).toBe('Running')
+ expect(store.tasks[3].taskType).toBe('History')
+ expect(store.tasks[4].taskType).toBe('History')
+ })
+
+ it('hasPendingTasks should be true when pending tasks exist', async () => {
+ mockGetQueue.mockResolvedValue({
+ Running: [],
+ Pending: [createPendingTask(1, 'pend-1')]
+ })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await store.update()
+ expect(store.hasPendingTasks).toBe(true)
+ })
+
+ it('hasPendingTasks should be false when no pending tasks', async () => {
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await store.update()
+ expect(store.hasPendingTasks).toBe(false)
+ })
+
+ it('lastHistoryQueueIndex should return highest queue index', async () => {
+ const hist1 = createHistoryTask(10, 'hist-1')
+ const hist2 = createHistoryTask(25, 'hist-2')
+ const hist3 = createHistoryTask(15, 'hist-3')
+
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({
+ History: [hist1, hist2, hist3]
+ })
+
+ await store.update()
+ expect(store.lastHistoryQueueIndex).toBe(25)
+ })
+
+ it('lastHistoryQueueIndex should be -1 when no history', async () => {
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await store.update()
+ expect(store.lastHistoryQueueIndex).toBe(-1)
+ })
+ })
+
+ describe('clear()', () => {
+ beforeEach(async () => {
+ mockGetQueue.mockResolvedValue({
+ Running: [createRunningTask(1, 'run-1')],
+ Pending: [createPendingTask(2, 'pend-1')]
+ })
+ mockGetHistory.mockResolvedValue({
+ History: [createHistoryTask(3, 'hist-1')]
+ })
+ await store.update()
+ })
+
+ it('should clear both queue and history by default', async () => {
+ mockClearItems.mockResolvedValue(undefined)
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await store.clear()
+
+ expect(mockClearItems).toHaveBeenCalledTimes(2)
+ expect(mockClearItems).toHaveBeenCalledWith('queue')
+ expect(mockClearItems).toHaveBeenCalledWith('history')
+ expect(store.runningTasks).toHaveLength(0)
+ expect(store.pendingTasks).toHaveLength(0)
+ expect(store.historyTasks).toHaveLength(0)
+ })
+
+ it('should clear only queue when specified', async () => {
+ mockClearItems.mockResolvedValue(undefined)
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({
+ History: [createHistoryTask(3, 'hist-1')]
+ })
+
+ await store.clear(['queue'])
+
+ expect(mockClearItems).toHaveBeenCalledTimes(1)
+ expect(mockClearItems).toHaveBeenCalledWith('queue')
+ expect(store.historyTasks).toHaveLength(1)
+ })
+
+ it('should clear only history when specified', async () => {
+ mockClearItems.mockResolvedValue(undefined)
+ mockGetQueue.mockResolvedValue({
+ Running: [createRunningTask(1, 'run-1')],
+ Pending: [createPendingTask(2, 'pend-1')]
+ })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await store.clear(['history'])
+
+ expect(mockClearItems).toHaveBeenCalledTimes(1)
+ expect(mockClearItems).toHaveBeenCalledWith('history')
+ expect(store.runningTasks).toHaveLength(1)
+ expect(store.pendingTasks).toHaveLength(1)
+ })
+
+ it('should do nothing when empty array passed', async () => {
+ await store.clear([])
+
+ expect(mockClearItems).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('delete()', () => {
+ it('should delete task from queue', async () => {
+ const task = new TaskItemImpl('Pending', createTaskPrompt(1, 'pend-1'))
+
+ mockDeleteItem.mockResolvedValue(undefined)
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await store.delete(task)
+
+ expect(mockDeleteItem).toHaveBeenCalledWith('queue', 'pend-1')
+ })
+
+ it('should delete task from history', async () => {
+ const task = new TaskItemImpl(
+ 'History',
+ createTaskPrompt(1, 'hist-1'),
+ createTaskStatus(),
+ createTaskOutput()
+ )
+
+ mockDeleteItem.mockResolvedValue(undefined)
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await store.delete(task)
+
+ expect(mockDeleteItem).toHaveBeenCalledWith('history', 'hist-1')
+ })
+
+ it('should refresh store after deletion', async () => {
+ const task = new TaskItemImpl('Pending', createTaskPrompt(1, 'pend-1'))
+
+ mockDeleteItem.mockResolvedValue(undefined)
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue({ History: [] })
+
+ await store.delete(task)
+
+ expect(mockGetQueue).toHaveBeenCalled()
+ expect(mockGetHistory).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/tests-ui/tests/store/subgraphStore.test.ts b/tests-ui/tests/store/subgraphStore.test.ts
index b97e1b149..d41c3e817 100644
--- a/tests-ui/tests/store/subgraphStore.test.ts
+++ b/tests-ui/tests/store/subgraphStore.test.ts
@@ -13,6 +13,11 @@ import {
createTestSubgraphNode
} from '../litegraph/subgraph/fixtures/subgraphHelpers'
+// Mock telemetry to break circular dependency (telemetry → workflowStore → app → telemetry)
+vi.mock('@/platform/telemetry', () => ({
+ useTelemetry: () => null
+}))
+
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
diff --git a/tests-ui/tests/stores/queueStore.loadWorkflow.test.ts b/tests-ui/tests/stores/queueStore.loadWorkflow.test.ts
new file mode 100644
index 000000000..3ff57f593
--- /dev/null
+++ b/tests-ui/tests/stores/queueStore.loadWorkflow.test.ts
@@ -0,0 +1,175 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { ComfyApp } from '@/scripts/app'
+import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
+import { TaskItemImpl } from '@/stores/queueStore'
+import * as getWorkflowModule from '@/platform/workflow/cloud'
+
+vi.mock('@/platform/distribution/types', () => ({
+ isCloud: true
+}))
+
+vi.mock('@/services/extensionService', () => ({
+ useExtensionService: vi.fn(() => ({
+ invokeExtensions: vi.fn()
+ }))
+}))
+
+const mockWorkflow: ComfyWorkflowJSON = {
+ id: 'test-workflow-id',
+ revision: 0,
+ last_node_id: 5,
+ last_link_id: 3,
+ nodes: [],
+ links: [],
+ groups: [],
+ config: {},
+ extra: {},
+ version: 0.4
+}
+
+const createHistoryTaskWithWorkflow = (): TaskItemImpl => {
+ return new TaskItemImpl(
+ 'History',
+ [
+ 0, // queueIndex
+ 'test-prompt-id', // promptId
+ {}, // promptInputs
+ {
+ client_id: 'test-client',
+ extra_pnginfo: {
+ workflow: mockWorkflow
+ }
+ },
+ [] // outputsToExecute
+ ],
+ {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ },
+ {} // outputs
+ )
+}
+
+const createHistoryTaskWithoutWorkflow = (): TaskItemImpl => {
+ return new TaskItemImpl(
+ 'History',
+ [
+ 0,
+ 'test-prompt-id',
+ {},
+ {
+ client_id: 'test-client'
+ // No extra_pnginfo.workflow
+ },
+ []
+ ],
+ {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ },
+ {}
+ )
+}
+
+describe('TaskItemImpl.loadWorkflow - cloud history workflow fetching', () => {
+ let mockApp: ComfyApp
+ let mockFetchApi: ReturnType
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.clearAllMocks()
+
+ mockFetchApi = vi.fn()
+ mockApp = {
+ loadGraphData: vi.fn(),
+ nodeOutputs: {},
+ api: {
+ fetchApi: mockFetchApi
+ }
+ } as unknown as ComfyApp
+
+ vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory')
+ })
+
+ it('should load workflow directly when workflow is in extra_pnginfo', async () => {
+ const task = createHistoryTaskWithWorkflow()
+
+ await task.loadWorkflow(mockApp)
+
+ expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
+ expect(mockFetchApi).not.toHaveBeenCalled()
+ })
+
+ it('should fetch workflow from cloud when workflow is missing from history task', async () => {
+ const task = createHistoryTaskWithoutWorkflow()
+
+ // Mock getWorkflowFromHistory to return workflow
+ vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
+ mockWorkflow
+ )
+
+ await task.loadWorkflow(mockApp)
+
+ expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalledWith(
+ expect.any(Function),
+ 'test-prompt-id'
+ )
+ expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
+ })
+
+ it('should not load workflow when fetch returns undefined', async () => {
+ const task = createHistoryTaskWithoutWorkflow()
+
+ vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
+ undefined
+ )
+
+ await task.loadWorkflow(mockApp)
+
+ expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled()
+ expect(mockApp.loadGraphData).not.toHaveBeenCalled()
+ })
+
+ it('should only fetch for history tasks, not running tasks', async () => {
+ const runningTask = new TaskItemImpl(
+ 'Running',
+ [
+ 0,
+ 'test-prompt-id',
+ {},
+ {
+ client_id: 'test-client'
+ },
+ []
+ ],
+ undefined,
+ {}
+ )
+
+ vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
+ mockWorkflow
+ )
+
+ await runningTask.loadWorkflow(mockApp)
+
+ expect(getWorkflowModule.getWorkflowFromHistory).not.toHaveBeenCalled()
+ expect(mockApp.loadGraphData).not.toHaveBeenCalled()
+ })
+
+ it('should handle fetch errors gracefully by returning undefined', async () => {
+ const task = createHistoryTaskWithoutWorkflow()
+
+ vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
+ undefined
+ )
+
+ await task.loadWorkflow(mockApp)
+
+ expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled()
+ expect(mockApp.loadGraphData).not.toHaveBeenCalled()
+ })
+})
diff --git a/tests-ui/tests/utils/formatUtil.test.ts b/tests-ui/tests/utils/formatUtil.test.ts
new file mode 100644
index 000000000..4ab0a7bd2
--- /dev/null
+++ b/tests-ui/tests/utils/formatUtil.test.ts
@@ -0,0 +1,145 @@
+import { describe, expect, it } from 'vitest'
+
+import { getMediaTypeFromFilename, truncateFilename } from '@/utils/formatUtil'
+
+describe('formatUtil', () => {
+ describe('truncateFilename', () => {
+ it('should not truncate short filenames', () => {
+ expect(truncateFilename('test.png')).toBe('test.png')
+ expect(truncateFilename('short.jpg', 10)).toBe('short.jpg')
+ })
+
+ it('should truncate long filenames while preserving extension', () => {
+ const longName = 'this-is-a-very-long-filename-that-needs-truncation.png'
+ const truncated = truncateFilename(longName, 20)
+ expect(truncated).toContain('...')
+ expect(truncated.endsWith('.png')).toBe(true)
+ expect(truncated.length).toBeLessThanOrEqual(25) // 20 + '...' + extension
+ })
+
+ it('should handle filenames without extensions', () => {
+ const longName = 'this-is-a-very-long-filename-without-extension'
+ const truncated = truncateFilename(longName, 20)
+ expect(truncated).toContain('...')
+ expect(truncated.length).toBeLessThanOrEqual(23) // 20 + '...'
+ })
+
+ it('should handle empty strings', () => {
+ expect(truncateFilename('')).toBe('')
+ expect(truncateFilename('', 10)).toBe('')
+ })
+
+ it('should preserve the start and end of the filename', () => {
+ const longName = 'ComfyUI_00001_timestamp_2024_01_01.png'
+ const truncated = truncateFilename(longName, 20)
+ expect(truncated).toMatch(/^ComfyUI.*01\.png$/)
+ expect(truncated).toContain('...')
+ })
+
+ it('should handle files with multiple dots', () => {
+ const filename = 'my.file.with.multiple.dots.txt'
+ const truncated = truncateFilename(filename, 15)
+ expect(truncated.endsWith('.txt')).toBe(true)
+ expect(truncated).toContain('...')
+ })
+ })
+
+ describe('getMediaTypeFromFilename', () => {
+ describe('image files', () => {
+ const imageTestCases = [
+ { filename: 'test.png', expected: 'image' },
+ { filename: 'photo.jpg', expected: 'image' },
+ { filename: 'image.jpeg', expected: 'image' },
+ { filename: 'animation.gif', expected: 'image' },
+ { filename: 'web.webp', expected: 'image' },
+ { filename: 'bitmap.bmp', expected: 'image' }
+ ]
+
+ it.for(imageTestCases)(
+ 'should identify $filename as $expected',
+ ({ filename, expected }) => {
+ expect(getMediaTypeFromFilename(filename)).toBe(expected)
+ }
+ )
+
+ it('should handle uppercase extensions', () => {
+ expect(getMediaTypeFromFilename('test.PNG')).toBe('image')
+ expect(getMediaTypeFromFilename('photo.JPG')).toBe('image')
+ })
+ })
+
+ describe('video files', () => {
+ it('should identify video extensions correctly', () => {
+ expect(getMediaTypeFromFilename('video.mp4')).toBe('video')
+ expect(getMediaTypeFromFilename('clip.webm')).toBe('video')
+ expect(getMediaTypeFromFilename('movie.mov')).toBe('video')
+ expect(getMediaTypeFromFilename('film.avi')).toBe('video')
+ })
+ })
+
+ describe('audio files', () => {
+ it('should identify audio extensions correctly', () => {
+ expect(getMediaTypeFromFilename('song.mp3')).toBe('audio')
+ expect(getMediaTypeFromFilename('sound.wav')).toBe('audio')
+ expect(getMediaTypeFromFilename('music.ogg')).toBe('audio')
+ expect(getMediaTypeFromFilename('audio.flac')).toBe('audio')
+ })
+ })
+
+ describe('3D files', () => {
+ it('should identify 3D file extensions correctly', () => {
+ expect(getMediaTypeFromFilename('model.obj')).toBe('3D')
+ expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
+ expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
+ expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle empty strings', () => {
+ expect(getMediaTypeFromFilename('')).toBe('image')
+ })
+
+ it('should handle files without extensions', () => {
+ expect(getMediaTypeFromFilename('README')).toBe('image')
+ })
+
+ it('should handle unknown extensions', () => {
+ expect(getMediaTypeFromFilename('document.pdf')).toBe('image')
+ expect(getMediaTypeFromFilename('data.json')).toBe('image')
+ })
+
+ it('should handle files with multiple dots', () => {
+ expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
+ expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image')
+ })
+
+ it('should handle paths with directories', () => {
+ expect(getMediaTypeFromFilename('/path/to/image.png')).toBe('image')
+ expect(getMediaTypeFromFilename('C:\\Windows\\video.mp4')).toBe('video')
+ })
+
+ it('should handle null and undefined gracefully', () => {
+ expect(getMediaTypeFromFilename(null as any)).toBe('image')
+ expect(getMediaTypeFromFilename(undefined as any)).toBe('image')
+ })
+
+ it('should handle special characters in filenames', () => {
+ expect(getMediaTypeFromFilename('test@#$.png')).toBe('image')
+ expect(getMediaTypeFromFilename('video (1).mp4')).toBe('video')
+ expect(getMediaTypeFromFilename('[2024] audio.mp3')).toBe('audio')
+ })
+
+ it('should handle very long filenames', () => {
+ const longFilename = 'a'.repeat(1000) + '.png'
+ expect(getMediaTypeFromFilename(longFilename)).toBe('image')
+ })
+
+ it('should handle mixed case extensions', () => {
+ expect(getMediaTypeFromFilename('test.PnG')).toBe('image')
+ expect(getMediaTypeFromFilename('video.Mp4')).toBe('video')
+ expect(getMediaTypeFromFilename('audio.WaV')).toBe('audio')
+ })
+ })
+ })
+})
diff --git a/tests-ui/tests/utils/graphTraversalUtil.test.ts b/tests-ui/tests/utils/graphTraversalUtil.test.ts
index f6cb74804..2af5a6ee9 100644
--- a/tests-ui/tests/utils/graphTraversalUtil.test.ts
+++ b/tests-ui/tests/utils/graphTraversalUtil.test.ts
@@ -35,14 +35,18 @@ function createMockNode(
isSubgraph?: boolean
subgraph?: Subgraph
callback?: () => void
+ graph?: LGraph
} = {}
): LGraphNode {
- return {
+ const node = {
id,
isSubgraphNode: options.isSubgraph ? () => true : undefined,
subgraph: options.subgraph,
- onExecutionStart: options.callback
+ onExecutionStart: options.callback,
+ graph: options.graph
} as unknown as LGraphNode
+ options.graph?.nodes?.push(node)
+ return node
}
// Mock graph factory
@@ -50,20 +54,28 @@ function createMockGraph(nodes: LGraphNode[]): LGraph {
return {
_nodes: nodes,
nodes: nodes,
+ isRootGraph: true,
getNodeById: (id: string | number) =>
nodes.find((n) => String(n.id) === String(id)) || null
} as unknown as LGraph
}
// Mock subgraph factory
-function createMockSubgraph(id: string, nodes: LGraphNode[]): Subgraph {
- return {
+function createMockSubgraph(
+ id: string,
+ nodes: LGraphNode[],
+ rootGraph?: LGraph
+): Subgraph {
+ const graph = {
id,
_nodes: nodes,
nodes: nodes,
+ isRootGraph: false,
+ rootGraph,
getNodeById: (nodeId: string | number) =>
nodes.find((n) => String(n.id) === String(nodeId)) || null
} as unknown as Subgraph
+ return graph
}
describe('graphTraversalUtil', () => {
@@ -983,31 +995,30 @@ describe('graphTraversalUtil', () => {
describe('getExecutionIdsForSelectedNodes', () => {
it('should return simple IDs for top-level nodes', () => {
- const nodes = [
- createMockNode('123'),
- createMockNode('456'),
- createMockNode('789')
- ]
+ const graph = createMockGraph([])
+ createMockNode('123', { graph })
+ createMockNode('456', { graph })
+ createMockNode('789', { graph })
- const executionIds = getExecutionIdsForSelectedNodes(nodes)
+ const executionIds = getExecutionIdsForSelectedNodes(graph.nodes)
expect(executionIds).toEqual(['789', '456', '123']) // DFS processes in LIFO order
})
it('should expand subgraph nodes to include all children', () => {
+ const graph = createMockGraph([])
const subNodes = [createMockNode('10'), createMockNode('11')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
- const nodes = [
- createMockNode('1'),
- createMockNode('2', { isSubgraph: true, subgraph })
- ]
+ createMockNode('1', { graph })
+ createMockNode('2', { isSubgraph: true, subgraph, graph })
- const executionIds = getExecutionIdsForSelectedNodes(nodes)
+ const executionIds = getExecutionIdsForSelectedNodes(graph.nodes)
expect(executionIds).toEqual(['2', '2:10', '2:11', '1']) // DFS: node 2 first, then its children
})
it('should handle deeply nested subgraphs correctly', () => {
+ const graph = createMockGraph([])
const deepNodes = [createMockNode('30'), createMockNode('31')]
const deepSubgraph = createMockSubgraph('deep-uuid', deepNodes)
@@ -1019,7 +1030,8 @@ describe('graphTraversalUtil', () => {
const topNode = createMockNode('10', {
isSubgraph: true,
- subgraph: midSubgraph
+ subgraph: midSubgraph,
+ graph
})
const executionIds = getExecutionIdsForSelectedNodes([topNode])
@@ -1028,16 +1040,15 @@ describe('graphTraversalUtil', () => {
})
it('should handle mixed selection of regular and subgraph nodes', () => {
+ const graph = createMockGraph([])
const subNodes = [createMockNode('100'), createMockNode('101')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
- const nodes = [
- createMockNode('1'),
- createMockNode('2', { isSubgraph: true, subgraph }),
- createMockNode('3')
- ]
+ createMockNode('1', { graph })
+ createMockNode('2', { isSubgraph: true, subgraph, graph })
+ createMockNode('3', { graph })
- const executionIds = getExecutionIdsForSelectedNodes(nodes)
+ const executionIds = getExecutionIdsForSelectedNodes(graph.nodes)
expect(executionIds).toEqual([
'3',
@@ -1054,10 +1065,12 @@ describe('graphTraversalUtil', () => {
})
it('should handle subgraph with no children', () => {
+ const graph = createMockGraph([])
const emptySubgraph = createMockSubgraph('empty-uuid', [])
const node = createMockNode('1', {
isSubgraph: true,
- subgraph: emptySubgraph
+ subgraph: emptySubgraph,
+ graph
})
const executionIds = getExecutionIdsForSelectedNodes([node])
@@ -1079,9 +1092,11 @@ describe('graphTraversalUtil', () => {
currentSubgraph = createMockSubgraph(`deep-${i}`, [node])
}
+ const graph = createMockGraph([])
const topNode = createMockNode('1', {
isSubgraph: true,
- subgraph: currentSubgraph
+ subgraph: currentSubgraph,
+ graph
})
const executionIds = getExecutionIdsForSelectedNodes([topNode])
@@ -1103,12 +1118,11 @@ describe('graphTraversalUtil', () => {
createMockNode('101') // Same ID as in subgraph1
])
- const nodes = [
- createMockNode('1', { isSubgraph: true, subgraph: subgraph1 }),
- createMockNode('2', { isSubgraph: true, subgraph: subgraph2 })
- ]
+ const graph = createMockGraph([])
+ createMockNode('1', { isSubgraph: true, subgraph: subgraph1, graph })
+ createMockNode('2', { isSubgraph: true, subgraph: subgraph2, graph })
- const executionIds = getExecutionIdsForSelectedNodes(nodes)
+ const executionIds = getExecutionIdsForSelectedNodes(graph.nodes)
expect(executionIds).toEqual([
'2',
@@ -1128,9 +1142,11 @@ describe('graphTraversalUtil', () => {
}
const bigSubgraph = createMockSubgraph('big-uuid', manyNodes)
+ const graph = createMockGraph([])
const node = createMockNode('parent', {
isSubgraph: true,
- subgraph: bigSubgraph
+ subgraph: bigSubgraph,
+ graph
})
const start = performance.now()
@@ -1157,19 +1173,17 @@ describe('graphTraversalUtil', () => {
})
const midSubgraph = createMockSubgraph('mid-uuid', [midNode1, midNode2])
- const topNode = createMockNode('100', {
- isSubgraph: true,
- subgraph: midSubgraph
- })
-
+ const graph = createMockGraph([])
// Select nodes at different nesting levels
- const selectedNodes = [
- createMockNode('1'), // Root level
- topNode, // Contains subgraph
- createMockNode('2') // Root level
- ]
+ createMockNode('100', {
+ isSubgraph: true,
+ subgraph: midSubgraph,
+ graph
+ })
+ createMockNode('1', { graph })
+ createMockNode('2', { graph })
- const executionIds = getExecutionIdsForSelectedNodes(selectedNodes)
+ const executionIds = getExecutionIdsForSelectedNodes(graph.nodes)
expect(executionIds).toContain('1')
expect(executionIds).toContain('2')
@@ -1178,6 +1192,17 @@ describe('graphTraversalUtil', () => {
expect(executionIds).toContain('100:202')
expect(executionIds).toContain('100:202:300')
})
+ it('should resolve full execution path of a node inside a subgraph', () => {
+ const graph = createMockGraph([])
+ const subgraph = createMockSubgraph('sub-uuid', [], graph)
+ createMockNode('11', { graph: subgraph })
+ createMockNode('10', { graph: subgraph })
+ createMockNode('2', { isSubgraph: true, subgraph, graph })
+
+ const executionIds = getExecutionIdsForSelectedNodes(subgraph.nodes)
+
+ expect(executionIds).toEqual(['2:10', '2:11'])
+ })
})
})
})
diff --git a/vite.config.mts b/vite.config.mts
index b2424dc43..fdeadc086 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -1,13 +1,17 @@
+import { sentryVitePlugin } from '@sentry/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { config as dotenvConfig } from 'dotenv'
+import type { IncomingMessage, ServerResponse } from 'http'
+import { Readable } from 'stream'
+import type { ReadableStream as NodeReadableStream } from 'stream/web'
import { visualizer } from 'rollup-plugin-visualizer'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
-import type { UserConfig } from 'vite'
+import type { ProxyOptions, UserConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
@@ -23,6 +27,15 @@ const VITE_REMOTE_DEV = process.env.VITE_REMOTE_DEV === 'true'
const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
+// Open Graph / Twitter Meta Tags Constants
+const VITE_OG_URL = 'https://cloud.comfy.org'
+const VITE_OG_TITLE =
+ 'Comfy Cloud: Run ComfyUI online | Zero Setup, Powerful GPUs, Create anywhere'
+const VITE_OG_DESC =
+ 'Bring your creative ideas to life with Comfy Cloud. Build and run your workflows to generate stunning images and videos instantly using powerful GPUs — all from your browser, no installation required.'
+const VITE_OG_IMAGE = `${VITE_OG_URL}/assets/images/og-image.png`
+const VITE_OG_KEYWORDS = 'ComfyUI, Comfy Cloud, ComfyUI online'
+
// Auto-detect cloud mode from DEV_SERVER_COMFYUI_URL
const DEV_SERVER_COMFYUI_ENV_URL = process.env.DEV_SERVER_COMFYUI_URL
const IS_CLOUD_URL = DEV_SERVER_COMFYUI_ENV_URL?.includes('.comfy.org')
@@ -49,12 +62,76 @@ const DEV_SEVER_FALLBACK_URL =
const DEV_SERVER_COMFYUI_URL =
DEV_SERVER_COMFYUI_ENV_URL || DEV_SEVER_FALLBACK_URL
-// Cloud proxy configuration
const cloudProxyConfig =
DISTRIBUTION === 'cloud' ? { secure: false, changeOrigin: true } : {}
+function handleGcsRedirect(
+ proxyRes: IncomingMessage,
+ _req: IncomingMessage,
+ res: ServerResponse
+) {
+ const location = proxyRes.headers.location
+ const isGcsRedirect =
+ proxyRes.statusCode === 302 &&
+ location?.includes('storage.googleapis.com') &&
+ proxyRes.headers.via?.includes('google')
+
+ // Not a GCS redirect - pass through normally
+ if (!isGcsRedirect || !location) {
+ Object.keys(proxyRes.headers).forEach((key) => {
+ const value = proxyRes.headers[key]
+ if (value !== undefined) {
+ res.setHeader(key, value)
+ }
+ })
+ res.writeHead(proxyRes.statusCode || 200)
+ proxyRes.pipe(res)
+ return
+ }
+
+ // GCS redirect detected - fetch server-side to avoid CORS
+ fetch(location)
+ .then(async (gcsResponse) => {
+ if (!gcsResponse.body) {
+ res.statusCode = 500
+ res.end('Empty response from GCS')
+ return
+ }
+
+ // Set response headers from GCS
+ res.statusCode = 200
+ res.setHeader(
+ 'Content-Type',
+ gcsResponse.headers.get('content-type') || 'application/octet-stream'
+ )
+
+ const contentLength = gcsResponse.headers.get('content-length')
+ if (contentLength) {
+ res.setHeader('Content-Length', contentLength)
+ }
+
+ // Convert Web ReadableStream to Node.js stream and pipe to client
+ const readable = Readable.fromWeb(gcsResponse.body as NodeReadableStream)
+ readable.pipe(res)
+ })
+ .catch((error) => {
+ console.error('Error fetching from GCS:', error)
+ res.statusCode = 500
+ res.end('Error fetching media')
+ })
+}
+
+const gcsRedirectProxyConfig: ProxyOptions = {
+ target: DEV_SERVER_COMFYUI_URL,
+ ...cloudProxyConfig,
+ selfHandleResponse: true,
+ configure: (proxy) => {
+ proxy.on('proxyRes', handleGcsRedirect)
+ }
+}
+
export default defineConfig({
- base: '',
+ base: DISTRIBUTION === 'cloud' ? '/' : '',
server: {
host: VITE_REMOTE_DEV ? '0.0.0.0' : undefined,
watch: {
@@ -80,6 +157,13 @@ export default defineConfig({
...cloudProxyConfig
},
+ ...(DISTRIBUTION === 'cloud'
+ ? {
+ '/api/view': gcsRedirectProxyConfig,
+ '/api/viewvideo': gcsRedirectProxyConfig
+ }
+ : {}),
+
'/api': {
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig,
@@ -147,41 +231,130 @@ export default defineConfig({
: [vue()]),
tailwindcss(),
comfyAPIPlugin(IS_DEV),
- generateImportMapPlugin([
- {
- name: 'vue',
- pattern: 'vue',
- entry: './dist/vue.esm-browser.prod.js'
- },
- {
- name: 'vue-i18n',
- pattern: 'vue-i18n',
- entry: './dist/vue-i18n.esm-browser.prod.js'
- },
- {
- name: 'primevue',
- pattern: /^primevue\/?.*/,
- entry: './index.mjs',
- recursiveDependence: true
- },
- {
- name: '@primevue/themes',
- pattern: /^@primevue\/themes\/?.*/,
- entry: './index.mjs',
- recursiveDependence: true
- },
- {
- name: '@primevue/forms',
- pattern: /^@primevue\/forms\/?.*/,
- entry: './index.mjs',
- recursiveDependence: true,
- override: {
- '@primeuix/forms': {
- entry: ''
- }
+ // Twitter/Open Graph meta tags plugin (cloud distribution only)
+ {
+ name: 'inject-twitter-meta',
+ transformIndexHtml(html) {
+ if (DISTRIBUTION !== 'cloud') return html
+
+ return {
+ html,
+ tags: [
+ // Basic SEO
+ { tag: 'title', children: VITE_OG_TITLE, injectTo: 'head' },
+ {
+ tag: 'meta',
+ attrs: { name: 'description', content: VITE_OG_DESC },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { name: 'keywords', content: VITE_OG_KEYWORDS },
+ injectTo: 'head'
+ },
+
+ // Twitter Card tags
+ {
+ tag: 'meta',
+ attrs: { name: 'twitter:card', content: 'summary_large_image' },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { name: 'twitter:title', content: VITE_OG_TITLE },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { name: 'twitter:description', content: VITE_OG_DESC },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { name: 'twitter:image', content: VITE_OG_IMAGE },
+ injectTo: 'head'
+ },
+
+ // Open Graph tags (Twitter fallback & other platforms)
+ {
+ tag: 'meta',
+ attrs: { property: 'og:title', content: VITE_OG_TITLE },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:description', content: VITE_OG_DESC },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:image', content: VITE_OG_IMAGE },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:url', content: VITE_OG_URL },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:type', content: 'website' },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:site_name', content: 'Comfy Cloud' },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:locale', content: 'en_US' },
+ injectTo: 'head'
+ }
+ ]
}
}
- ]),
+ },
+ // Skip import-map generation for cloud builds to keep bundle small
+ ...(DISTRIBUTION !== 'cloud'
+ ? [
+ generateImportMapPlugin([
+ {
+ name: 'vue',
+ pattern: 'vue',
+ entry: './dist/vue.esm-browser.prod.js'
+ },
+ {
+ name: 'vue-i18n',
+ pattern: 'vue-i18n',
+ entry: './dist/vue-i18n.esm-browser.prod.js'
+ },
+ {
+ name: 'primevue',
+ pattern: /^primevue\/?.*/,
+ entry: './index.mjs',
+ recursiveDependence: true
+ },
+ {
+ name: '@primevue/themes',
+ pattern: /^@primevue\/themes\/?.*/,
+ entry: './index.mjs',
+ recursiveDependence: true
+ },
+ {
+ name: '@primevue/forms',
+ pattern: /^@primevue\/forms\/?.*/,
+ entry: './index.mjs',
+ recursiveDependence: true,
+ override: {
+ '@primeuix/forms': {
+ entry: ''
+ }
+ }
+ }
+ ])
+ ]
+ : []),
Icons({
compiler: 'vue3',
@@ -209,12 +382,33 @@ export default defineConfig({
? [
visualizer({
filename: 'dist/stats.html',
- open: false,
+ open: true,
gzipSize: true,
brotliSize: true,
template: 'treemap' // or 'sunburst', 'network'
})
]
+ : []),
+
+ // Sentry sourcemap upload plugin
+ // Only runs during cloud production builds when all Sentry env vars are present
+ // Requires: SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT env vars
+ ...(DISTRIBUTION === 'cloud' &&
+ process.env.SENTRY_AUTH_TOKEN &&
+ process.env.SENTRY_ORG &&
+ process.env.SENTRY_PROJECT &&
+ !IS_DEV
+ ? [
+ sentryVitePlugin({
+ org: process.env.SENTRY_ORG,
+ project: process.env.SENTRY_PROJECT,
+ authToken: process.env.SENTRY_AUTH_TOKEN,
+ sourcemaps: {
+ // Delete source maps after upload to prevent public access
+ filesToDeleteAfterUpload: ['**/*.map']
+ }
+ })
+ ]
: [])
],
@@ -242,8 +436,12 @@ export default defineConfig({
return 'vendor-chart'
}
- if (id.includes('three') || id.includes('@xterm')) {
- return 'vendor-visualization'
+ if (id.includes('three')) {
+ return 'vendor-three'
+ }
+
+ if (id.includes('@xterm')) {
+ return 'vendor-xterm'
}
if (id.includes('/vue') || id.includes('pinia')) {