Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
af29f850ce fix: refactor: Use real composable implementations in ComfyHubPublishDialog tests instead of heavy mocks (#9449)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:43:15 +01:00
4 changed files with 197 additions and 69 deletions

View File

@@ -178,7 +178,7 @@
"uploadAlreadyInProgress": "Upload already in progress",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} node | {count} nodes",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
"addNode": "Add a node...",
"filterBy": "Filter by:",
"filterByType": "Filter by {type}...",
@@ -222,7 +222,7 @@
"failed": "Failed",
"cancelled": "Cancelled",
"job": "Job",
"asset": "{count} asset | {count} assets",
"asset": "{count} assets | {count} asset | {count} assets",
"untitled": "Untitled",
"emDash": "—",
"enabling": "Enabling {id}",
@@ -3347,7 +3347,7 @@
}
},
"errorOverlay": {
"errorCount": "{count} ERROR | {count} ERRORS",
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",
"seeErrors": "See Errors"
},
"help": {
@@ -3357,7 +3357,7 @@
"progressToast": {
"importingModels": "Importing Models",
"downloadingModel": "Downloading model...",
"downloadsFailed": "{count} download failed | {count} downloads failed",
"downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed",
"allDownloadsCompleted": "All downloads completed",
"noImportsInQueue": "No {filter} in queue",
"failed": "Failed",
@@ -3374,7 +3374,7 @@
"exportingAssets": "Exporting Assets",
"preparingExport": "Preparing export...",
"exportError": "Export failed",
"exportFailed": "{count} export failed | {count} exports failed",
"exportFailed": "{count} export failed | {count} export failed | {count} exports failed",
"allExportsCompleted": "All exports completed",
"noExportsInQueue": "No {filter} exports in queue",
"exportStarted": "Preparing ZIP download...",

View File

@@ -1,59 +1,79 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
const mockFetchProfile = vi.hoisted(() => vi.fn())
const mockGoToStep = vi.hoisted(() => vi.fn())
const mockGoNext = vi.hoisted(() => vi.fn())
const mockGoBack = vi.hoisted(() => vi.fn())
const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
const mockFetchApi = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockResolvedUserInfo = vi.hoisted(() => ({
value: { id: 'user-a' }
}))
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
() => ({
useComfyHubProfileGate: () => ({
fetchProfile: mockFetchProfile
})
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: mockFetchApi
}
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
resolvedUserInfo: mockResolvedUserInfo
})
)
}))
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubPublishWizard',
() => ({
useComfyHubPublishWizard: () => ({
currentStep: ref('finish'),
formData: ref({
name: '',
description: '',
workflowType: '',
tags: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
}),
isFirstStep: ref(false),
isLastStep: ref(true),
goToStep: mockGoToStep,
goNext: mockGoNext,
goBack: mockGoBack,
openProfileCreationStep: mockOpenProfileCreationStep,
closeProfileCreationStep: mockCloseProfileCreationStep
})
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: mockToastErrorHandler
})
)
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { filename: 'test-workflow' }
})
}))
const mockProfile: ComfyHubProfile = {
username: 'testuser',
name: 'Test User',
description: 'A test profile'
}
function mockSuccessResponse(data?: unknown) {
return {
ok: true,
json: async () => data ?? mockProfile
} as Response
}
function mockErrorResponse(status = 404) {
return {
ok: false,
status,
json: async () => ({ message: 'Not found' })
} as Response
}
// Reset module-level singleton state in useComfyHubProfileGate between tests
async function resetProfileGateSingleton() {
const { useComfyHubProfileGate } =
await import('@/platform/workflow/sharing/composables/useComfyHubProfileGate')
const gate = useComfyHubProfileGate()
gate.hasProfile.value = null
gate.profile.value = null
gate.isCheckingProfile.value = false
gate.isFetchingProfile.value = false
}
describe('ComfyHubPublishDialog', () => {
const onClose = vi.fn()
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks()
mockFetchProfile.mockResolvedValue(null)
mockResolvedUserInfo.value = { id: 'user-a' }
mockFetchApi.mockResolvedValue(mockErrorResponse())
await resetProfileGateSingleton()
})
function createWrapper() {
@@ -78,7 +98,7 @@ describe('ComfyHubPublishDialog', () => {
},
ComfyHubPublishWizardContent: {
template:
'<div><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /></div>',
'<div><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /><span data-testid="current-step">{{ $props.currentStep }}</span></div>',
props: [
'currentStep',
'formData',
@@ -88,7 +108,9 @@ describe('ComfyHubPublishDialog', () => {
'onGoBack',
'onRequireProfile',
'onGateComplete',
'onGateClose'
'onGateClose',
'onUpdateFormData',
'onPublish'
]
}
}
@@ -96,44 +118,62 @@ describe('ComfyHubPublishDialog', () => {
})
}
it('starts in publish wizard mode and prefetches profile asynchronously', async () => {
it('prefetches profile on mount via real composable', async () => {
createWrapper()
await flushPromises()
expect(mockFetchProfile).toHaveBeenCalledWith()
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profile')
})
it('switches to profile creation step when final-step publish requires profile', async () => {
it('starts on the describe step with real wizard composable', async () => {
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.find('[data-testid="current-step"]').text()).toBe('describe')
})
it('switches to profileCreation step when require-profile is triggered', async () => {
const wrapper = createWrapper()
await flushPromises()
await wrapper.find('[data-testid="require-profile"]').trigger('click')
expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
expect(wrapper.find('[data-testid="current-step"]').text()).toBe(
'profileCreation'
)
})
it('returns to finish state after gate complete and does not auto-close', async () => {
it('returns to finish step and re-fetches profile after gate complete', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
const wrapper = createWrapper()
await flushPromises()
await wrapper.find('[data-testid="require-profile"]').trigger('click')
expect(wrapper.find('[data-testid="current-step"]').text()).toBe(
'profileCreation'
)
await wrapper.find('[data-testid="gate-complete"]').trigger('click')
await flushPromises()
expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
expect(mockFetchProfile).toHaveBeenCalledWith({ force: true })
expect(wrapper.find('[data-testid="current-step"]').text()).toBe('finish')
// Initial prefetch + force re-fetch after gate complete
expect(mockFetchApi).toHaveBeenCalledTimes(2)
expect(onClose).not.toHaveBeenCalled()
})
it('returns to finish state when profile gate is closed', async () => {
it('returns to finish step when profile gate is closed', async () => {
const wrapper = createWrapper()
await flushPromises()
await wrapper.find('[data-testid="require-profile"]').trigger('click')
expect(wrapper.find('[data-testid="current-step"]').text()).toBe(
'profileCreation'
)
await wrapper.find('[data-testid="gate-close"]').trigger('click')
expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
expect(wrapper.find('[data-testid="current-step"]').text()).toBe('finish')
expect(onClose).not.toHaveBeenCalled()
})
})

View File

@@ -7,11 +7,18 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import * as litegraphUtil from '@/utils/litegraphUtil'
vi.mock('@/utils/litegraphUtil', () => ({
isAnimatedOutput: vi.fn(),
isVideoNode: vi.fn()
}))
const mockGetNodeById = vi.fn()
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
rootGraph: {
getNodeById: (...args: unknown[]) => mockGetNodeById(...args)
},
@@ -144,6 +151,76 @@ describe('nodeOutputStore restoreOutputs', () => {
})
})
describe('nodeOutputStore getPreviewParam', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(false)
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false)
})
it('should return empty string if output is animated', () => {
const store = useNodeOutputStore()
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(true)
const node = createMockNode()
const outputs = createMockOutputs([{ filename: 'img.png' }])
expect(store.getPreviewParam(node, outputs)).toBe('')
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
})
it('should return empty string if isVideoNode returns true', () => {
const store = useNodeOutputStore()
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(true)
const node = createMockNode()
const outputs = createMockOutputs([{ filename: 'img.png' }])
expect(store.getPreviewParam(node, outputs)).toBe('')
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
})
it('should return empty string if outputs.images is undefined', () => {
const store = useNodeOutputStore()
const node = createMockNode()
const outputs: ExecutedWsMessage['output'] = {}
expect(store.getPreviewParam(node, outputs)).toBe('')
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
})
it('should return empty string if outputs.images is empty', () => {
const store = useNodeOutputStore()
const node = createMockNode()
const outputs = createMockOutputs([])
expect(store.getPreviewParam(node, outputs)).toBe('')
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
})
it('should return empty string if outputs.images contains SVG images', () => {
const store = useNodeOutputStore()
const node = createMockNode()
const outputs = createMockOutputs([{ filename: 'img.svg' }])
expect(store.getPreviewParam(node, outputs)).toBe('')
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
})
it('should return format param for standard image outputs', () => {
const store = useNodeOutputStore()
const node = createMockNode()
const outputs = createMockOutputs([{ filename: 'img.png' }])
expect(store.getPreviewParam(node, outputs)).toBe('&format=test_webp')
expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1)
})
it('should return format param for multiple standard images', () => {
const store = useNodeOutputStore()
const node = createMockNode()
const outputs = createMockOutputs([
{ filename: 'img1.png' },
{ filename: 'img2.jpg' }
])
expect(store.getPreviewParam(node, outputs)).toBe('&format=test_webp')
expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1)
})
})
describe('nodeOutputStore syncLegacyNodeImgs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -11,8 +11,6 @@ import type {
ResultItemType
} from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { clone } from '@/scripts/utils'
@@ -99,6 +97,20 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
return true
}
/**
* Get the preview param for the node's outputs.
*
* If the output is an image, use the user's preferred format (from settings).
* For non-image outputs, return an empty string, as including the preview param
* will force the server to load the output file as an image.
*/
function getPreviewParam(
node: LGraphNode,
outputs: ExecutedWsMessage['output']
): string {
return isImageOutputs(node, outputs) ? app.getPreviewFormatParam() : ''
}
function getNodeImageUrls(node: LGraphNode): string[] | undefined {
const previews = getNodePreviews(node)
if (previews?.length) return previews
@@ -106,17 +118,14 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
const outputs = getNodeOutputs(node)
if (!outputs?.images?.length) return
const rand = app.getRandParam()
const previewParam = getPreviewParam(node, outputs)
const isImage = isImageOutputs(node, outputs)
return outputs.images.map((image) => {
const params = new URLSearchParams(image)
if (isImage) {
appendCloudResParam(params, image.filename)
const previewFormat = useSettingStore().get('Comfy.PreviewFormat')
if (previewFormat) params.set('preview', previewFormat)
}
if (!isCloud) params.set('rand', String(Math.random()))
return api.apiURL(`/view?${params}`)
if (isImage) appendCloudResParam(params, image.filename)
return api.apiURL(`/view?${params}${previewParam}${rand}`)
})
}
@@ -434,6 +443,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
getNodeOutputs,
getNodeImageUrls,
getNodePreviews,
getPreviewParam,
// Setters
setNodeOutputs,
setNodeOutputsByExecutionId,