Compare commits

..

2 Commits

Author SHA1 Message Date
Marwan Mostafa
0dd68bec34 fix: correct Pylon form base URL to workspace subdomain
Pylon serves a workspace's forms from the workspace subdomain
(comfy-org.portal.usepylon.com/forms/<slug>), not a path segment under
portal.usepylon.com. Update the base URL and every hardcoded reference in
the unit and browser tests so support links resolve to real forms.
2026-07-02 15:12:07 +03:00
Marwan Ahmed
4626cb80a8 feat: migrate support links from Zendesk to Pylon with context-aware routing
Replaces the Zendesk ticket form URL builder with a Pylon prefill builder and
routes each Support entry-point to the best-fit Pylon form, prefilled with the
user's email, cloud user id, environment, frontend version, OS, and browser.

- Help Center "Help" / topbar "Support" -> question form
- Error dialog & node "Get Help" -> report-a-bug form
- Subscription dialog, useSubscriptionActions, credits panel -> billing-refund-issue form
- Mobile linear-mode error -> report-a-bug form
- Cloud onboarding (signup, auth timeout, footer) -> question form

OS is normalized so Python platform names (darwin/linux/win32) are promoted to
UA-detected versions ("macOS 14.5", "Windows 10/11"); the Typeform feedback URL
is unchanged.
2026-07-02 15:12:06 +03:00
37 changed files with 577 additions and 4002 deletions

View File

@@ -80,19 +80,23 @@ class HelpCenterHelper {
}
/**
* Intercept the Zendesk support URL so it never actually loads in the
* new tab opened by the Contact Support command.
* Intercept the Pylon support URL (and the legacy Zendesk one for safety)
* so it never actually loads in the new tab opened by the Contact Support
* command.
*/
async stubSupportPage(): Promise<void> {
await this.page
.context()
.route('https://support.comfy.org/**', (route: Route) =>
for (const pattern of [
'https://comfy-org.portal.usepylon.com/**',
'https://support.comfy.org/**'
]) {
await this.page.context().route(pattern, (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
}
/**

View File

@@ -103,14 +103,14 @@ test.describe('Settings', () => {
})
test.describe('Support', () => {
test('Should open external zendesk link with OSS tag', async ({
test('Should open Pylon question form with OSS environment tag', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Prevent loading the external page
await comfyPage.page
.context()
.route('https://support.comfy.org/**', (route) =>
.route('https://comfy-org.portal.usepylon.com/**', (route) =>
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
)
@@ -119,8 +119,9 @@ test.describe('Support', () => {
const popup = await popupPromise
const url = new URL(popup.url())
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
expect(url.pathname).toBe('/forms/question')
expect(url.searchParams.get('comfy_environment')).toBe('oss')
await popup.close()
})

View File

@@ -122,9 +122,15 @@ test.describe('Error dialog', () => {
await popup.close()
})
test('Should open contact support when "Help Fix This" is clicked', async ({
test('Should open the Pylon bug-report form when "Help Fix This" is clicked', async ({
comfyPage
}) => {
await comfyPage.page
.context()
.route('https://comfy-org.portal.usepylon.com/**', (route) =>
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
)
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
@@ -133,7 +139,9 @@ test.describe('Error dialog', () => {
)
const url = new URL(popup.url())
expect(url.hostname).toBe('support.comfy.org')
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
expect(url.pathname).toBe('/forms/report-a-bug')
expect(url.searchParams.get('product_area')).toBe('Workflow Error')
await popup.close()
})

View File

@@ -99,26 +99,28 @@ test.describe('Help Center', () => {
expect(url.pathname).toBe('/Comfy-Org/ComfyUI')
})
test('Help & Support item opens the Zendesk support form with OSS tag', async ({
test('Help & Support item opens the Pylon question form tagged as OSS', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('help').click()
)
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
expect(url.pathname).toBe('/forms/question')
expect(url.searchParams.get('comfy_environment')).toBe('oss')
})
test('Give Feedback item opens Contact Support in OSS mode', async ({
test('Give Feedback item opens the Pylon question form in OSS mode', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('feedback').click()
)
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
expect(url.pathname).toBe('/forms/question')
expect(url.searchParams.get('comfy_environment')).toBe('oss')
})
})

View File

@@ -70,10 +70,11 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { generateErrorReport } from '@/utils/errorReportUtil'
import type { ErrorReportData } from '@/utils/errorReportUtil'
@@ -115,16 +116,18 @@ const title = computed<string>(
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
)
const { openSupport } = useSupportContext()
/**
* Open contact support flow from error dialog and track telemetry.
*/
const showContactSupport = async () => {
const showContactSupport = () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
await useCommandStore().execute('Comfy.ContactSupport')
openSupport(SupportForm.Bug, { productArea: 'Workflow Error' })
}
onMounted(async () => {

View File

@@ -45,14 +45,15 @@ import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
import { useAuthStore } from '@/stores/authStore'
import { useCommandStore } from '@/stores/commandStore'
const { buildDocsUrl, docsPaths } = useExternalLink()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const { openSupport } = useSupportContext()
const telemetry = useTelemetry()
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
@@ -70,13 +71,13 @@ const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = async () => {
const handleMessageSupport = () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
openSupport(SupportForm.Billing, { productArea: 'Credits' })
}
const handleFaqClick = () => {

View File

@@ -49,6 +49,13 @@ vi.mock('@/stores/commandStore', () => ({
}))
}))
const mockOpenSupport = vi.fn()
vi.mock('@/platform/support/useSupportContext', () => ({
useSupportContext: vi.fn(() => ({
openSupport: mockOpenSupport
}))
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
staticUrls: {
@@ -353,7 +360,7 @@ describe('ErrorNodeCard.vue', () => {
openSpy.mockRestore()
})
it('executes ContactSupport command when Get Help button is clicked', async () => {
it('opens the Pylon bug-report form when Get Help button is clicked', async () => {
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
@@ -362,7 +369,9 @@ describe('ErrorNodeCard.vue', () => {
await user.click(screen.getByRole('button', { name: /Get Help/ }))
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mockOpenSupport).toHaveBeenCalledWith('report-a-bug', {
productArea: 'Workflow Error'
})
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
expect.objectContaining({
resource_type: 'help_feedback',

View File

@@ -5,7 +5,7 @@ import { useErrorActions } from './useErrorActions'
const mocks = vi.hoisted(() => ({
trackUiButtonClicked: vi.fn(),
trackHelpResourceClicked: vi.fn(),
execute: vi.fn(),
openSupport: vi.fn(),
telemetry: null as {
trackUiButtonClicked: ReturnType<typeof vi.fn>
trackHelpResourceClicked: ReturnType<typeof vi.fn>
@@ -15,9 +15,9 @@ const mocks = vi.hoisted(() => ({
}
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mocks.execute
vi.mock('@/platform/support/useSupportContext', () => ({
useSupportContext: () => ({
openSupport: mocks.openSupport
})
}))
@@ -41,7 +41,7 @@ describe('useErrorActions', () => {
}
mocks.trackUiButtonClicked.mockReset()
mocks.trackHelpResourceClicked.mockReset()
mocks.execute.mockReset()
mocks.openSupport.mockReset()
windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null as unknown as Window)
@@ -84,36 +84,31 @@ describe('useErrorActions', () => {
})
describe('contactSupport', () => {
it('tracks the help resource click and executes the contact support command', () => {
mocks.execute.mockReturnValue('executed')
it('tracks the help resource click and opens the Pylon bug-report form', () => {
const { contactSupport } = useErrorActions()
const result = contactSupport()
contactSupport()
expect(mocks.trackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(result).toBe('executed')
expect(mocks.openSupport).toHaveBeenCalledWith('report-a-bug', {
productArea: 'Workflow Error'
})
})
it('returns the execute promise when the command is async', async () => {
mocks.execute.mockResolvedValue('done')
const { contactSupport } = useErrorActions()
await expect(contactSupport()).resolves.toBe('done')
})
it('still executes the command when telemetry is unavailable', () => {
it('still opens the support form when telemetry is unavailable', () => {
mocks.telemetry = null
const { contactSupport } = useErrorActions()
void contactSupport()
contactSupport()
expect(mocks.trackHelpResourceClicked).not.toHaveBeenCalled()
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mocks.openSupport).toHaveBeenCalledWith('report-a-bug', {
productArea: 'Workflow Error'
})
})
})

View File

@@ -1,10 +1,11 @@
import { useCommandStore } from '@/stores/commandStore'
import { useExternalLink } from '@/composables/useExternalLink'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
export function useErrorActions() {
const telemetry = useTelemetry()
const commandStore = useCommandStore()
const { openSupport } = useSupportContext()
const { staticUrls } = useExternalLink()
function openGitHubIssues() {
@@ -21,7 +22,7 @@ export function useErrorActions() {
is_external: true,
source: 'error_dialog'
})
return commandStore.execute('Comfy.ContactSupport')
openSupport(SupportForm.Bug, { productArea: 'Workflow Error' })
}
function findOnGitHub(errorMessage: string) {

View File

@@ -1,4 +1,3 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
@@ -23,7 +22,8 @@ import type { Point } from '@/lib/litegraph/src/litegraph'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
import type { ExecutionTriggerSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -864,12 +864,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
const { userEmail, resolvedUserInfo } = useCurrentUser()
const supportUrl = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(supportUrl, '_blank', 'noopener,noreferrer')
useSupportContext().openSupport(SupportForm.Question)
}
},
{

View File

@@ -1,85 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsApi } from './useAssetsApi'
const mockAssetsStore = vi.hoisted(() => ({
inputAssets: [] as AssetItem[],
historyAssets: [] as AssetItem[],
inputLoading: false,
historyLoading: false,
inputError: null as string | null,
historyError: null as string | null,
hasMoreHistory: false,
isLoadingMore: false,
updateInputs: vi.fn(),
updateHistory: vi.fn(),
loadMoreHistory: vi.fn()
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => mockAssetsStore
}))
function createAsset(id: string): AssetItem {
return {
id,
name: `${id}.png`,
size: 1,
created_at: '2026-01-01T00:00:00Z',
tags: ['input']
}
}
describe('useAssetsApi', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAssetsStore.inputAssets = [createAsset('input-1')]
mockAssetsStore.historyAssets = [createAsset('history-1')]
mockAssetsStore.inputLoading = true
mockAssetsStore.historyLoading = false
mockAssetsStore.inputError = 'input-error'
mockAssetsStore.historyError = 'history-error'
mockAssetsStore.hasMoreHistory = true
mockAssetsStore.isLoadingMore = true
})
it('uses input assets and refreshes inputs', async () => {
const api = useAssetsApi('input')
expect(api.media.value).toEqual([createAsset('input-1')])
expect(api.loading.value).toBe(true)
expect(api.error.value).toBe('input-error')
expect(api.hasMore.value).toBe(false)
expect(api.isLoadingMore.value).toBe(false)
await expect(api.fetchMediaList()).resolves.toEqual([
createAsset('input-1')
])
await expect(api.refresh()).resolves.toEqual([createAsset('input-1')])
await api.loadMore()
expect(mockAssetsStore.updateInputs).toHaveBeenCalledTimes(2)
expect(mockAssetsStore.updateHistory).not.toHaveBeenCalled()
expect(mockAssetsStore.loadMoreHistory).not.toHaveBeenCalled()
})
it('uses output history and loads more history', async () => {
const api = useAssetsApi('output')
expect(api.media.value).toEqual([createAsset('history-1')])
expect(api.loading.value).toBe(false)
expect(api.error.value).toBe('history-error')
expect(api.hasMore.value).toBe(true)
expect(api.isLoadingMore.value).toBe(true)
await expect(api.fetchMediaList()).resolves.toEqual([
createAsset('history-1')
])
await api.loadMore()
expect(mockAssetsStore.updateHistory).toHaveBeenCalledOnce()
expect(mockAssetsStore.updateInputs).not.toHaveBeenCalled()
expect(mockAssetsStore.loadMoreHistory).toHaveBeenCalledOnce()
})
})

View File

@@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema'
import { api } from '@/scripts/api'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema'
import type * as outputAssetUtilModule from '../utils/outputAssetUtil'
@@ -19,12 +18,6 @@ const mockIsCloud = vi.hoisted(() => ({ value: false }))
// Track the filename passed to createAnnotatedPath
const capturedFilenames = vi.hoisted(() => ({ values: [] as string[] }))
const capturedAnnotatedPaths = vi.hoisted(() => ({
values: [] as Array<{
item: { filename: string; subfolder?: string; type?: string }
options: { rootFolder?: string }
}>
}))
const mockDownloadFile = vi.hoisted(() => vi.fn())
vi.mock('@/base/common/downloadUtil', () => ({
@@ -80,10 +73,9 @@ vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({})
}))
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: mockCopyToClipboard
copyToClipboard: vi.fn()
})
}))
@@ -101,50 +93,45 @@ vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
extractWorkflowFromAsset: mockExtractWorkflowFromAsset
}))
const mockAddNodeOnGraph = vi.hoisted(() => vi.fn())
const mockGetCanvasCenter = vi.hoisted(() => vi.fn())
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({
addNodeOnGraph: mockAddNodeOnGraph,
getCanvasCenter: mockGetCanvasCenter
addNodeOnGraph: vi.fn().mockReturnValue(
fromAny<LGraphNode, unknown>({
widgets: [{ name: 'image', value: '', callback: vi.fn() }],
graph: { setDirtyCanvas: vi.fn() }
})
),
getCanvasCenter: vi.fn().mockReturnValue([100, 100])
})
}))
const mockNodeDefsByName = vi.hoisted(() => ({
value: {
LoadImage: {
name: 'LoadImage',
display_name: 'Load Image'
}
} as Record<string, unknown>
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeDefsByName: mockNodeDefsByName.value
nodeDefsByName: {
LoadImage: {
name: 'LoadImage',
display_name: 'Load Image'
}
}
})
}))
vi.mock('@/utils/createAnnotatedPath', () => ({
createAnnotatedPath: vi.fn(
(
item: { filename: string; subfolder?: string; type?: string },
options: { rootFolder?: string }
) => {
capturedAnnotatedPaths.values.push({ item, options })
capturedFilenames.values.push(item.filename)
return item.filename
}
)
createAnnotatedPath: vi.fn((item: { filename: string }) => {
capturedFilenames.values.push(item.filename)
return item.filename
})
}))
const mockDetectNodeTypeFromFilename = vi.hoisted(() => vi.fn())
vi.mock('@/utils/loaderNodeUtil', () => ({
detectNodeTypeFromFilename: mockDetectNodeTypeFromFilename
detectNodeTypeFromFilename: vi.fn().mockReturnValue({
nodeType: 'LoadImage',
widgetName: 'image'
})
}))
const mockIsResultItemType = vi.hoisted(() => vi.fn())
vi.mock('@/utils/typeGuardUtil', () => ({
isResultItemType: mockIsResultItemType
isResultItemType: vi.fn().mockReturnValue(true)
}))
const mockGetAssetType = vi.hoisted(() => vi.fn())
@@ -199,9 +186,7 @@ vi.mock('@/scripts/api', () => ({
}
}))
const mockAppGraph = vi.hoisted(() => ({
value: { _nodes: [] as unknown[] } as { _nodes: unknown[] } | null
}))
const mockAppGraph = vi.hoisted(() => ({ value: { _nodes: [] as unknown[] } }))
vi.mock('@/scripts/app', () => ({
app: {
get graph() {
@@ -306,43 +291,7 @@ describe('useMediaAssetActions', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
capturedFilenames.values = []
capturedAnnotatedPaths.values = []
mockIsCloud.value = false
mockAppGraph.value = { _nodes: [] }
mockDownloadFile.mockReset()
mockCopyToClipboard.mockReset()
mockShowDialog.mockReset()
mockAddNodeOnGraph.mockReset()
mockAddNodeOnGraph.mockReturnValue(
fromAny<LGraphNode, unknown>({
widgets: [{ name: 'image', value: '', callback: vi.fn() }],
graph: { setDirtyCanvas: vi.fn() }
})
)
mockGetCanvasCenter.mockReset()
mockGetCanvasCenter.mockReturnValue([100, 100])
mockNodeDefsByName.value = {
LoadImage: {
name: 'LoadImage',
display_name: 'Load Image'
}
}
mockDetectNodeTypeFromFilename.mockReset()
mockDetectNodeTypeFromFilename.mockReturnValue({
nodeType: 'LoadImage',
widgetName: 'image'
})
mockIsResultItemType.mockReset()
mockIsResultItemType.mockReturnValue(true)
mockExtractWorkflowFromAsset.mockReset()
mockOpenWorkflowAction.mockReset()
mockExportWorkflowAction.mockReset()
mockCreateAssetExport.mockReset()
mockCreateAssetExport.mockResolvedValue({
task_id: 'test-task-id',
status: 'pending'
})
mockDeleteAsset.mockReset()
mockGetOutputAssetMetadata.mockReset()
mockGetOutputAssetMetadata.mockReturnValue(null)
mockGetAssetType.mockReset()
@@ -350,139 +299,7 @@ describe('useMediaAssetActions', () => {
mockResolveOutputAssetItems.mockResolvedValue([])
})
describe('copyJobId', () => {
it('does nothing when no asset is available', async () => {
const { actions, unmount } = mountMediaActions()
await actions.copyJobId()
expect(mockCopyToClipboard).not.toHaveBeenCalled()
expect(useToast().add).not.toHaveBeenCalled()
unmount()
})
it('warns when the asset has no job id', async () => {
mockGetAssetType.mockReturnValue('input')
const actions = useMediaAssetActions()
await actions.copyJobId(createMockAsset())
expect(mockCopyToClipboard).not.toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('copies the metadata job id when present', async () => {
mockGetOutputAssetMetadata.mockReturnValue({ jobId: 'job-from-meta' })
const actions = useMediaAssetActions()
await actions.copyJobId(createMockAsset())
expect(mockCopyToClipboard).toHaveBeenCalledWith('job-from-meta')
})
it('copies the output asset id when metadata omits the job id', async () => {
mockGetAssetType.mockReturnValue('output')
const actions = useMediaAssetActions()
await actions.copyJobId(createMockAsset({ id: 'history-id' }))
expect(mockCopyToClipboard).toHaveBeenCalledWith('history-id')
})
})
describe('addWorkflow', () => {
it('does nothing when no asset is available', async () => {
const { actions, unmount } = mountMediaActions()
await actions.addWorkflow()
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(useToast().add).not.toHaveBeenCalled()
unmount()
})
it('uses the injected media asset when no explicit asset is provided', async () => {
const mediaAsset = createMockMediaAsset({ name: 'context-image.png' })
const { actions, unmount } = mountMediaActions(mediaAsset)
await actions.addWorkflow()
expect(capturedFilenames.values).toContain('context-image.png')
unmount()
})
it('warns when the filename has no compatible loader node', async () => {
mockDetectNodeTypeFromFilename.mockReturnValue({
nodeType: undefined,
widgetName: undefined
})
const actions = useMediaAssetActions()
await actions.addWorkflow(createMockAsset({ name: 'notes.txt' }))
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('reports missing node definitions', async () => {
mockNodeDefsByName.value = {}
const actions = useMediaAssetActions()
await actions.addWorkflow(createMockAsset())
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('reports loader-node creation failure', async () => {
mockAddNodeOnGraph.mockReturnValue(undefined)
const actions = useMediaAssetActions()
await actions.addWorkflow(createMockAsset())
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('still adds the node when the expected widget is absent', async () => {
const setDirtyCanvas = vi.fn()
mockAddNodeOnGraph.mockReturnValue(
fromAny<LGraphNode, unknown>({
widgets: [{ name: 'other', value: '' }],
graph: { setDirtyCanvas }
})
)
mockGetOutputAssetMetadata.mockReturnValue({ subfolder: 'nested' })
mockGetAssetType.mockReturnValue('custom')
mockIsResultItemType.mockReturnValue(false)
const actions = useMediaAssetActions()
await actions.addWorkflow(createMockAsset({ name: 'asset.png' }))
expect(capturedAnnotatedPaths.values.at(-1)).toEqual({
item: {
filename: 'asset.png',
subfolder: 'nested',
type: undefined
},
options: { rootFolder: 'input' }
})
expect(setDirtyCanvas).toHaveBeenCalledWith(true, true)
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
describe('OSS mode (isCloud = false)', () => {
beforeEach(() => {
mockIsCloud.value = false
@@ -549,83 +366,6 @@ describe('useMediaAssetActions', () => {
})
describe('addMultipleToWorkflow', () => {
it('does nothing for an empty selection', async () => {
const actions = useMediaAssetActions()
await actions.addMultipleToWorkflow([])
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(useToast().add).not.toHaveBeenCalled()
})
it('shows a failure toast when none of the selected assets can be added', async () => {
mockDetectNodeTypeFromFilename
.mockReturnValueOnce({ nodeType: undefined, widgetName: undefined })
.mockReturnValueOnce({ nodeType: 'MissingNode', widgetName: 'image' })
const actions = useMediaAssetActions()
await actions.addMultipleToWorkflow([
createMockAsset({ id: 'a', name: 'unsupported.txt' }),
createMockAsset({ id: 'b', name: 'missing.png' })
])
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('shows a partial warning when only some nodes are added', async () => {
mockAddNodeOnGraph
.mockReturnValueOnce(
fromAny<LGraphNode, unknown>({
widgets: [{ name: 'image', value: '', callback: vi.fn() }],
graph: { setDirtyCanvas: vi.fn() }
})
)
.mockReturnValueOnce(undefined)
const actions = useMediaAssetActions()
await actions.addMultipleToWorkflow([
createMockAsset({ id: 'a', name: 'a.png' }),
createMockAsset({ id: 'b', name: 'b.png' })
])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('adds assets without a matching widget using untyped paths', async () => {
const setDirtyCanvas = vi.fn()
mockAddNodeOnGraph.mockReturnValue(
fromAny<LGraphNode, unknown>({
widgets: [{ name: 'other', value: '' }],
graph: { setDirtyCanvas }
})
)
mockGetAssetType.mockReturnValue('custom')
mockIsResultItemType.mockReturnValue(false)
const actions = useMediaAssetActions()
await actions.addMultipleToWorkflow([
createMockAsset({ id: 'asset-1', name: 'asset-1.png' })
])
expect(capturedAnnotatedPaths.values.at(-1)).toEqual({
item: {
filename: 'asset-1.png',
subfolder: '',
type: undefined
},
options: { rootFolder: undefined }
})
expect(setDirtyCanvas).toHaveBeenCalledWith(true, true)
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
describe('Cloud mode (isCloud = true)', () => {
beforeEach(() => {
mockIsCloud.value = true
@@ -657,56 +397,10 @@ describe('useMediaAssetActions', () => {
})
})
describe('openWorkflow', () => {
beforeEach(() => {
mockExtractWorkflowFromAsset.mockResolvedValue({
workflow: { version: 0.4 },
filename: 'workflow.json'
})
})
it('does nothing when no asset is available', async () => {
const { actions, unmount } = mountMediaActions()
await actions.openWorkflow()
expect(mockExtractWorkflowFromAsset).not.toHaveBeenCalled()
expect(mockOpenWorkflowAction).not.toHaveBeenCalled()
unmount()
})
it('shows a success toast after opening the workflow', async () => {
mockOpenWorkflowAction.mockResolvedValue({ success: true })
const actions = useMediaAssetActions()
await actions.openWorkflow(createMockAsset())
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('uses the fallback warning when opening returns no error message', async () => {
mockOpenWorkflowAction.mockResolvedValue({ success: false })
const actions = useMediaAssetActions()
await actions.openWorkflow(createMockAsset())
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'warn',
detail: 'mediaAsset.noWorkflowDataFound'
})
)
})
})
describe('exportWorkflow', () => {
const successResult = { success: true } as const
const cancelledResult = { success: false, cancelled: true } as const
const failureResult = { success: false, error: 'boom' } as const
const failureWithoutError = { success: false } as const
const noWorkflowResult = {
success: false,
error: 'No workflow data available'
@@ -761,31 +455,6 @@ describe('useMediaAssetActions', () => {
)
})
it('does nothing when no asset is available', async () => {
const { actions, unmount } = mountMediaActions()
await actions.exportWorkflow()
expect(mockExtractWorkflowFromAsset).not.toHaveBeenCalled()
expect(mockExportWorkflowAction).not.toHaveBeenCalled()
unmount()
})
it('uses the fallback error when export fails without a message', async () => {
mockExportWorkflowAction.mockResolvedValue(failureWithoutError)
const actions = useMediaAssetActions()
await actions.exportWorkflow(createMockAsset())
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'mediaAsset.failedToExportWorkflow'
})
)
})
it('shows no toast when every asset in a bulk export is cancelled', async () => {
mockExportWorkflowAction.mockResolvedValue(cancelledResult)
const actions = useMediaAssetActions()
@@ -831,118 +500,6 @@ describe('useMediaAssetActions', () => {
})
})
describe('openMultipleWorkflows', () => {
beforeEach(() => {
mockExtractWorkflowFromAsset.mockResolvedValue({
workflow: { version: 0.4 },
filename: 'workflow.json'
})
})
it('does nothing for an empty selection', async () => {
const actions = useMediaAssetActions()
await actions.openMultipleWorkflows([])
expect(mockOpenWorkflowAction).not.toHaveBeenCalled()
expect(useToast().add).not.toHaveBeenCalled()
})
it('shows success when every workflow opens', async () => {
mockOpenWorkflowAction.mockResolvedValue({ success: true })
const actions = useMediaAssetActions()
await actions.openMultipleWorkflows([
createMockAsset({ id: 'a' }),
createMockAsset({ id: 'b' })
])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('shows a missing-workflow warning when none open', async () => {
mockOpenWorkflowAction.mockResolvedValue({ success: false })
const actions = useMediaAssetActions()
await actions.openMultipleWorkflows([
createMockAsset({ id: 'a' }),
createMockAsset({ id: 'b' })
])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('shows a partial warning when extraction throws for one asset', async () => {
mockExtractWorkflowFromAsset
.mockResolvedValueOnce({
workflow: { version: 0.4 },
filename: 'ok.json'
})
.mockRejectedValueOnce(new Error('missing workflow'))
mockOpenWorkflowAction.mockResolvedValue({ success: true })
const actions = useMediaAssetActions()
await actions.openMultipleWorkflows([
createMockAsset({ id: 'a' }),
createMockAsset({ id: 'b' })
])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
})
describe('exportMultipleWorkflows', () => {
beforeEach(() => {
mockExtractWorkflowFromAsset.mockResolvedValue({
workflow: { version: 0.4 },
filename: 'workflow.json'
})
})
it('does nothing for an empty selection', async () => {
const actions = useMediaAssetActions()
await actions.exportMultipleWorkflows([])
expect(mockExportWorkflowAction).not.toHaveBeenCalled()
expect(useToast().add).not.toHaveBeenCalled()
})
it('shows no-workflows warning when every export fails', async () => {
mockExportWorkflowAction.mockResolvedValue({
success: false,
error: 'boom'
})
const actions = useMediaAssetActions()
await actions.exportMultipleWorkflows([
createMockAsset({ id: 'a' }),
createMockAsset({ id: 'b' })
])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('counts extraction failures as failed exports', async () => {
mockExtractWorkflowFromAsset.mockRejectedValue(new Error('missing'))
const actions = useMediaAssetActions()
await actions.exportMultipleWorkflows([createMockAsset()])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
})
describe('downloadAssets', () => {
it('downloads the injected media asset when called without explicit assets', () => {
const mediaAsset = createMockMediaAsset({
@@ -977,36 +534,6 @@ describe('useMediaAssetActions', () => {
unmount()
})
it('uses the asset URL when no preview URL is available', () => {
mockGetAssetType.mockReturnValue('input')
const asset = createMockAsset({
name: 'raw image.png',
preview_url: undefined,
user_metadata: { subfolder: 'uploads' }
})
const actions = useMediaAssetActions()
actions.downloadAssets([asset])
expect(mockDownloadFile).toHaveBeenCalledWith(
'http://localhost:8188/api/view?filename=raw+image.png&type=input&subfolder=uploads',
'raw image.png'
)
})
it('shows an error toast when a direct download throws', () => {
mockDownloadFile.mockImplementation(() => {
throw new Error('download failed')
})
const actions = useMediaAssetActions()
actions.downloadAssets([createMockAsset()])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('keeps single explicit assets on the direct download path in cloud', () => {
mockIsCloud.value = true
mockGetOutputAssetMetadata.mockReturnValue({
@@ -1416,82 +943,6 @@ describe('useMediaAssetActions', () => {
})
expect(payload.naming_strategy).toBe('preserve')
})
it('should include asset ids for imported assets', async () => {
mockGetAssetType.mockImplementation((asset: AssetItem) =>
asset.tags?.includes('output') ? 'output' : 'input'
)
const asset1 = createMockAsset({ id: 'input-1', tags: ['input'] })
const asset2 = createMockAsset({ id: 'input-2', tags: ['input'] })
const actions = useMediaAssetActions()
actions.downloadAssets([asset1, asset2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_ids).toBeUndefined()
expect(payload.asset_ids).toEqual(['input-1', 'input-2'])
expect(payload.naming_strategy).toBe('preserve')
})
it('should mix output job ids and imported asset ids', async () => {
mockGetAssetType.mockImplementation((asset: AssetItem) =>
asset.tags?.includes('output') ? 'output' : 'input'
)
const output = createMockAsset({
id: 'history-id',
name: 'output.png',
tags: ['output']
})
const imported = createMockAsset({ id: 'input-id', tags: ['input'] })
const actions = useMediaAssetActions()
actions.downloadAssets([output, imported])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_ids).toEqual(['history-id'])
expect(payload.asset_ids).toEqual(['input-id'])
})
it('should only include a filtered output name once', async () => {
const asset1 = createOutputAsset('a1', 'same.png', 'job1')
const asset2 = createOutputAsset('a2', 'same.png', 'job1')
const actions = useMediaAssetActions()
actions.downloadAssets([asset1, asset2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_asset_name_filters).toEqual({
job1: ['same.png']
})
})
it('should show an error toast when ZIP export creation fails', async () => {
mockCreateAssetExport.mockRejectedValueOnce(new Error('export failed'))
const asset1 = createOutputAsset('a1', 'img1.png', 'job1')
const asset2 = createOutputAsset('a2', 'img2.png', 'job2')
const actions = useMediaAssetActions()
actions.downloadAssets([asset1, asset2])
await vi.waitFor(() => {
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
expect(mockTrackExport).not.toHaveBeenCalled()
})
})
describe('downloadAssets - export toast file count', () => {
@@ -1582,200 +1033,6 @@ describe('useMediaAssetActions', () => {
})
})
describe('deleteAssets', () => {
it('returns false for an empty selection', async () => {
const actions = useMediaAssetActions()
const result = await actions.deleteAssets([])
expect(result).toBe(false)
expect(mockShowDialog).not.toHaveBeenCalled()
})
it('returns false when the user cancels', async () => {
mockShowDialog.mockImplementation(
({ props }: { props: { onCancel: () => void } }) => {
props.onCancel()
}
)
const actions = useMediaAssetActions()
const result = await actions.deleteAssets(createMockAsset())
expect(result).toBe(false)
expect(mockDeleteAsset).not.toHaveBeenCalled()
})
it('rejects imported asset deletion outside cloud mode', async () => {
mockIsCloud.value = false
mockGetAssetType.mockReturnValue('input')
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets(createMockAsset({ tags: ['input'] }))
await vi.waitFor(() => {
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
expect(mockDeleteAsset).not.toHaveBeenCalled()
})
it('rejects output deletion when no job id can be resolved', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('output')
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets(
createMockAsset({ id: '', name: 'orphan.png', tags: ['output'] })
)
await vi.waitFor(() => {
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
expect(api.deleteItem).not.toHaveBeenCalled()
})
it('updates output history and input listings for mixed successful deletion', async () => {
mockIsCloud.value = true
mockGetAssetType.mockImplementation((asset: AssetItem) =>
asset.tags?.includes('output') ? 'output' : 'input'
)
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets([
createMockAsset({ id: 'history-1', tags: ['output'] }),
createMockAsset({ id: 'input-1', tags: ['input'] })
])
await vi.waitFor(() => {
expect(mockUpdateHistory).toHaveBeenCalled()
})
expect(mockUpdateInputs).toHaveBeenCalled()
expect(api.deleteItem).toHaveBeenCalledWith('history', 'history-1')
expect(mockDeleteAsset).toHaveBeenCalledWith('input-1')
})
it('skips graph cleanup when there is no root graph', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('input')
mockAppGraph.value = null
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets(createMockAsset({ tags: ['input'] }))
await vi.waitFor(() => {
expect(mockDeleteAsset).toHaveBeenCalled()
})
expect(mockClearNodePreviewCache).not.toHaveBeenCalled()
expect(mockClearWidgetValues).not.toHaveBeenCalled()
expect(mockCaptureCanvasState).not.toHaveBeenCalled()
})
it('uses temp widget-value variants when deleting temp assets', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('temp')
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets(
createMockAsset({
id: 'temp-1',
name: 'preview.png',
hash: 'preview-hash.png',
tags: ['temp']
})
)
await vi.waitFor(() => {
expect(mockClearNodePreviewCache).toHaveBeenCalled()
})
const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
expect(valuesArg).toEqual(
new Set(['preview.png [temp]', 'preview-hash.png'])
)
})
it('uses hash-only cleanup values when the asset name is empty', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('input')
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets(
createMockAsset({
id: 'hash-only',
name: '',
hash: 'only-hash.png',
tags: ['input']
})
)
await vi.waitFor(() => {
expect(mockClearNodePreviewCache).toHaveBeenCalled()
})
const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
expect(valuesArg).toEqual(new Set(['only-hash.png']))
})
it('shows a partial warning and cleans up only successfully deleted assets', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('input')
mockDeleteAsset
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('delete failed'))
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets([
createMockAsset({ id: 'ok', name: 'ok.png', tags: ['input'] }),
createMockAsset({ id: 'bad', name: 'bad.png', tags: ['input'] })
])
await vi.waitFor(() => {
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
expect(valuesArg).toEqual(new Set(['ok.png', 'ok.png [input]']))
})
})
describe('deleteAssets - model cache invalidation', () => {
beforeEach(() => {
mockIsCloud.value = true

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, ref } from 'vue'
@@ -15,7 +14,6 @@ vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: vi.fn(),
uploadAssetAsync: vi.fn(),
uploadAssetFromBase64: vi.fn(),
uploadAssetPreviewImage: vi.fn()
}
}))
@@ -250,81 +248,6 @@ describe('useUploadModelWizard', () => {
expect(wizard.selectedModelType.value).toBe('checkpoints')
})
it('does not fetch metadata until the URL matches a supported source', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const wizard = setupUploadModelWizard(modelTypes)
expect(wizard.canFetchMetadata.value).toBe(false)
await wizard.fetchMetadata()
expect(assetService.getAssetMetadata).not.toHaveBeenCalled()
expect(wizard.currentStep.value).toBe(1)
})
it('decodes metadata filenames and selects a matching model type tag', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
content_length: 100,
final_url: 'https://huggingface.co/org/model',
filename: '%E6%A8%A1%E5%9E%8B.safetensors',
name: '%E5%90%8D%E7%A8%B1',
tags: ['checkpoints'],
preview_image: 'data:image/png;base64,abc'
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = ' https://huggingface.co/org/model '
await wizard.fetchMetadata()
expect(wizard.currentStep.value).toBe(2)
expect(wizard.wizardData.value.url).toBe('https://huggingface.co/org/model')
expect(wizard.wizardData.value.name).toBe('模型.safetensors')
expect(wizard.wizardData.value.previewImage).toBe(
'data:image/png;base64,abc'
)
expect(wizard.selectedModelType.value).toBe('checkpoints')
})
it('keeps metadata text when percent decoding fails', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: '%E0%A4%A',
name: '%E0%A4%A',
tags: []
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.fetchMetadata()
expect(wizard.currentStep.value).toBe(2)
expect(wizard.wizardData.value.name).toBe('%E0%A4%A')
expect(wizard.selectedModelType.value).toBeUndefined()
})
it('uses the fallback metadata error for non-error rejections', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockRejectedValue('no metadata')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.fetchMetadata()
expect(wizard.currentStep.value).toBe(1)
expect(wizard.uploadError.value).toBe(
'Failed to retrieve metadata. Please check the link and try again.'
)
})
it('uploads with the required model type even if selection changes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
@@ -356,382 +279,6 @@ describe('useUploadModelWizard', () => {
expect(result?.modelType).toBe('checkpoints')
})
it('clears upload errors and type mismatches when the URL changes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.uploadModel()
expect(wizard.uploadTypeMismatch.value).not.toBeNull()
wizard.wizardData.value.url = 'https://civitai.com/models/54321'
await nextTick()
expect(wizard.uploadError.value).toBe('')
expect(wizard.uploadTypeMismatch.value).toBeNull()
})
it('returns null while another upload is in progress', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
type UploadResult = Awaited<
ReturnType<typeof assetService.uploadAssetAsync>
>
let resolveUpload!: (value: UploadResult) => void
vi.mocked(assetService.uploadAssetAsync).mockReturnValue(
new Promise<UploadResult>((resolve) => {
resolveUpload = resolve
})
)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const firstUpload = wizard.uploadModel()
await nextTick()
await expect(wizard.uploadModel()).resolves.toBeNull()
resolveUpload({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
await expect(firstUpload).resolves.toEqual(
expect.objectContaining({ status: 'success' })
)
})
it('returns null when no model type is selected', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(assetService.uploadAssetAsync).not.toHaveBeenCalled()
})
it('reports an upload error when no valid source is detected', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://example.com/model'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(assetService.uploadAssetAsync).not.toHaveBeenCalled()
})
it('uploads preview images and passes the preview id to the model upload', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetFromBase64).mockResolvedValue(
fromPartial({ id: 'preview-1' })
)
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: 'model.safetensors'
}
wizard.wizardData.value.previewImage = 'data:image/jpeg;base64,abc'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(assetService.uploadAssetFromBase64).toHaveBeenCalledWith({
data: 'data:image/jpeg;base64,abc',
name: 'model_preview.jpg',
tags: ['preview']
})
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
expect.objectContaining({ preview_id: 'preview-1' })
)
})
it('continues model upload when preview upload fails', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetFromBase64).mockRejectedValue(
new Error('preview failed')
)
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url: 'https://civitai.com/models/12345',
name: 'model'
}
wizard.wizardData.value.previewImage = 'data:image/webp;base64,abc'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
expect.objectContaining({ preview_id: undefined })
)
expect(wizard.uploadStatus.value).toBe('success')
})
it('treats an already completed async upload as success', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-complete',
status: 'completed',
message: 'Download complete'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: 'queued.safetensors'
}
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'queued.safetensors',
modelType: 'checkpoints',
status: 'success'
})
expect(wizard.uploadStatus.value).toBe('success')
})
it('cleans up an immediately resolved async watcher', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const { useAssetDownloadStore } =
await import('@/stores/assetDownloadStore')
const assetDownloadStore = useAssetDownloadStore()
assetDownloadStore.trackDownload(
'task-ready',
'checkpoints',
'ready.safetensors'
)
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
expect(handler).toBeDefined()
handler!(
new CustomEvent('asset_download', {
detail: {
task_id: 'task-ready',
asset_id: 'asset-ready',
asset_name: 'ready.safetensors',
bytes_total: 100,
bytes_downloaded: 100,
progress: 100,
status: 'completed' as const
}
})
)
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-ready',
status: 'created',
message: 'Download queued'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
await nextTick()
expect(wizard.uploadStatus.value).toBe('success')
})
it('uses the default failed-download message when no error is available', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-fallback-fail',
status: 'created',
message: 'Download queued'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
expect(handler).toBeDefined()
handler!(
new CustomEvent('asset_download', {
detail: {
task_id: 'task-fallback-fail',
asset_id: '',
asset_name: '',
bytes_total: 1000,
bytes_downloaded: 500,
progress: 50,
status: 'failed' as const
}
})
)
await nextTick()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadError.value).toBe('assetBrowser.downloadFailed')
})
it('uses fallback labels for unknown mismatch types', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-unknown',
name: 'model.safetensors',
tags: ['models']
}
})
const wizard = setupUploadModelWizard(modelTypes, {
requiredModelType: 'unknown-required'
})
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(wizard.uploadTypeMismatch.value).toEqual({
importedModelType: undefined,
importedModelTypeLabel: undefined,
requiredModelType: 'unknown-required',
requiredModelTypeLabel: 'unknown-required'
})
})
it('uses a generic upload error for non-error upload failures', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockRejectedValue('failed')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadError.value).toBe('Failed to upload model')
})
it('navigates backward only after the first step', () => {
const wizard = setupUploadModelWizard(modelTypes)
wizard.goToPreviousStep()
expect(wizard.currentStep.value).toBe(1)
wizard.currentStep.value = 3
wizard.goToPreviousStep()
expect(wizard.currentStep.value).toBe(2)
})
it('resets wizard state and cancels pending async status watching', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-reset',
status: 'created',
message: 'Download queued'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.name = 'Model'
wizard.wizardData.value.tags = ['checkpoints']
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
wizard.resetWizard()
expect(wizard.currentStep.value).toBe(1)
expect(wizard.uploadStatus.value).toBeUndefined()
expect(wizard.uploadError.value).toBe('')
expect(wizard.wizardData.value).toEqual({
url: '',
name: '',
tags: []
})
expect(wizard.selectedModelType.value).toBeUndefined()
})
it('returns the synced asset filename for sync imports', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')

View File

@@ -12,7 +12,6 @@ import { api } from '@/scripts/api'
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
const mockGetCategoryForNodeType = vi.hoisted(() => vi.fn())
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -34,7 +33,7 @@ vi.mock('@/stores/modelToNodeStore', () => {
return {
useModelToNodeStore: vi.fn(() => ({
getRegisteredNodeTypes: () => registeredNodeTypes,
getCategoryForNodeType: mockGetCategoryForNodeType
getCategoryForNodeType: vi.fn()
}))
}
})
@@ -173,28 +172,6 @@ describe(assetService.getAssetMetadata, () => {
).rejects.toThrow('File too large')
})
it('falls back to the unknown localized message for unrecognized error codes', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({ code: 'NOT_A_REAL_CODE' }, { ok: false, status: 400 })
)
await expect(
assetService.getAssetMetadata('https://example.com/model.safetensors')
).rejects.toThrow('Unknown error')
})
it('falls back to unknown when error JSON cannot be parsed', async () => {
fetchApiMock.mockResolvedValueOnce({
ok: false,
status: 400,
json: vi.fn().mockRejectedValue(new Error('bad json'))
} as unknown as Response)
await expect(
assetService.getAssetMetadata('https://example.com/model.safetensors')
).rejects.toThrow('Unknown error')
})
it('throws a localized message when validation reports is_valid=false', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
@@ -212,20 +189,6 @@ describe(assetService.getAssetMetadata, () => {
).rejects.toThrow('Unsafe virus scan')
})
it('falls back to unknown when validation errors are absent', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
content_length: 100,
final_url: 'https://example.com/model.safetensors',
validation: { is_valid: false }
})
)
await expect(
assetService.getAssetMetadata('https://example.com/model.safetensors')
).rejects.toThrow('Unknown error')
})
it('encodes the URL in the query string', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
@@ -245,115 +208,12 @@ describe(assetService.getAssetMetadata, () => {
})
})
describe(assetService.getAssetsForNodeType, () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetCategoryForNodeType.mockReset()
})
it('returns an empty list for invalid node types without fetching', async () => {
await expect(assetService.getAssetsForNodeType('')).resolves.toEqual([])
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('returns an empty list when the node type has no asset category', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
await expect(
assetService.getAssetsForNodeType('UnknownNode')
).resolves.toEqual([])
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('fetches category assets with default pagination', async () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
const assets = [
validAsset({ id: 'ckpt-1', tags: ['models', 'checkpoints'] })
]
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
await expect(
assetService.getAssetsForNodeType('CheckpointLoaderSimple')
).resolves.toEqual(assets)
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_tags')).toBe('models,checkpoints')
expect(params.get('limit')).toBe('500')
expect(params.has('offset')).toBe(false)
})
it('passes positive offsets for category asset pagination', async () => {
mockGetCategoryForNodeType.mockReturnValue('loras')
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse([]))
await assetService.getAssetsForNodeType('LoraLoader', {
limit: 25,
offset: 50
})
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_tags')).toBe('models,loras')
expect(params.get('limit')).toBe('25')
expect(params.get('offset')).toBe('50')
})
})
describe(assetService.getAssetDetails, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws when the details response is not ok', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({}, { ok: false, status: 404 })
)
await expect(assetService.getAssetDetails('missing')).rejects.toThrow(
'Unable to load asset details for missing: Server returned 404'
)
})
it('throws when the details response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ id: 'asset-1' }))
await expect(assetService.getAssetDetails('asset-1')).rejects.toThrow(
/Invalid asset response/
)
})
it('returns validated asset details', async () => {
const asset = validAsset({ id: 'asset-details' })
fetchApiMock.mockResolvedValueOnce(buildResponse(asset))
await expect(
assetService.getAssetDetails('asset-details')
).resolves.toEqual(asset)
})
})
describe(assetService.uploadAssetFromUrl, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('throws when URL upload returns a non-ok response', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 500 })
)
await expect(
assetService.uploadAssetFromUrl({
url: 'https://example.com/input.png',
name: 'input.png'
})
).rejects.toThrow('Failed to upload asset')
})
it('does not invalidate cached input assets when the upload response is invalid', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -434,61 +294,6 @@ describe(assetService.uploadAssetFromBase64, () => {
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('throws when base64 upload returns a non-ok response', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
try {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 507 })
)
await expect(
assetService.uploadAssetFromBase64({
data: 'data:text/plain;base64,aGVsbG8=',
name: 'input.txt'
})
).rejects.toThrow('Failed to upload asset from base64: 507')
} finally {
fetchSpy.mockRestore()
}
})
it('posts base64 uploads with tags and user metadata', async () => {
const uploadedAsset = {
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
created_new: false
}
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
try {
fetchApiMock.mockResolvedValueOnce(buildResponse(uploadedAsset))
const result = await assetService.uploadAssetFromBase64({
data: 'data:text/plain;base64,aGVsbG8=',
name: 'input.txt',
tags: ['input', 'mask'],
user_metadata: { source: 'paste' }
})
expect(result).toEqual(uploadedAsset)
const request = fetchApiMock.mock.calls[0]?.[1]
expect(request).toEqual(expect.objectContaining({ method: 'POST' }))
expect(request?.body).toBeInstanceOf(FormData)
const formData = request?.body
if (!(formData instanceof FormData)) {
throw new Error('Expected base64 upload body to be FormData')
}
expect(formData.get('tags')).toBe(JSON.stringify(['input', 'mask']))
expect(formData.get('user_metadata')).toBe(
JSON.stringify({ source: 'paste' })
)
} finally {
fetchSpy.mockRestore()
}
})
it('does not invalidate cached input assets when the upload response is invalid', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -550,7 +355,6 @@ describe(assetService.uploadAssetFromBase64, () => {
describe(assetService.uploadAssetAsync, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('returns an async result when the server responds 202', async () => {
@@ -585,64 +389,6 @@ describe(assetService.uploadAssetAsync, () => {
asset: expect.objectContaining({ id: 'asset-2' })
})
})
it('throws when the async upload response is not ok', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 502 })
)
await expect(
assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors'
})
).rejects.toThrow('Failed to upload asset')
})
it('throws when an async upload task response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({ task_id: 'task-1', status: 'waiting' }, { status: 202 })
)
await expect(
assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors'
})
).rejects.toThrow('Failed to parse async upload response')
})
it('throws when a sync upload asset response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ id: 'asset-2' }))
await expect(
assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors'
})
).rejects.toThrow('Failed to parse sync upload response')
})
it('invalidates cached input assets for completed async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'completed' },
{ ok: true, status: 202 }
)
)
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
source_url: 'https://example.com/input.png',
tags: ['input']
})
const refreshed = await assetService.getInputAssetsIncludingPublic()
expect(refreshed).toEqual(freshAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(3)
})
})
describe(assetService.deleteAsset, () => {
@@ -670,94 +416,6 @@ describe(assetService.deleteAsset, () => {
})
})
describe(assetService.addAssetTags, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('posts tags and returns the parsed tag operation result', async () => {
const result = { total_tags: ['input', 'mask'], added: ['mask'] }
fetchApiMock.mockResolvedValueOnce(buildResponse(result))
await expect(
assetService.addAssetTags('asset-1', ['mask'])
).resolves.toEqual(result)
expect(fetchApiMock).toHaveBeenCalledWith(
'/assets/asset-1/tags',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ tags: ['mask'] })
})
)
})
it('throws when adding tags fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 403 })
)
await expect(
assetService.addAssetTags('asset-1', ['mask'])
).rejects.toThrow(
'Unable to add tags to asset asset-1: Server returned 403'
)
})
it('throws when the add-tags response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ added: ['mask'] }))
await expect(
assetService.addAssetTags('asset-1', ['mask'])
).rejects.toThrow()
})
})
describe(assetService.removeAssetTags, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('deletes tags and returns the parsed tag operation result', async () => {
const result = { total_tags: ['input'], removed: ['mask'] }
fetchApiMock.mockResolvedValueOnce(buildResponse(result))
await expect(
assetService.removeAssetTags('asset-1', ['mask'])
).resolves.toEqual(result)
expect(fetchApiMock).toHaveBeenCalledWith(
'/assets/asset-1/tags',
expect.objectContaining({
method: 'DELETE',
body: JSON.stringify({ tags: ['mask'] })
})
)
})
it('throws when removing tags fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 404 })
)
await expect(
assetService.removeAssetTags('asset-1', ['mask'])
).rejects.toThrow(
'Unable to remove tags from asset asset-1: Server returned 404'
)
})
it('throws when the remove-tags response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ removed: ['mask'] }))
await expect(
assetService.removeAssetTags('asset-1', ['mask'])
).rejects.toThrow()
})
})
describe(assetService.getAssetModelFolders, () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -823,16 +481,6 @@ describe(assetService.updateAsset, () => {
})
)
})
it('throws when the update response is not ok', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 409 })
)
await expect(
assetService.updateAsset('asset-1', { name: 'renamed.safetensors' })
).rejects.toThrow('Unable to update asset asset-1: Server returned 409')
})
})
describe(assetService.getAssetsByTag, () => {
@@ -867,21 +515,6 @@ describe(assetService.getAssetsByTag, () => {
expect(params.get('include_tags')).toBe('input')
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
it('forwards explicit public filtering and offset pagination', async () => {
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse([]))
await assetService.getAssetsByTag('input', false, {
limit: 30,
offset: 60
})
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_public')).toBe('false')
expect(params.get('limit')).toBe('30')
expect(params.get('offset')).toBe('60')
})
})
describe(assetService.getAllAssetsByTag, () => {
@@ -929,31 +562,6 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.has('offset')).toBe(false)
})
it('uses the default page size when limit is not positive', async () => {
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse([]))
await expect(
assetService.getAllAssetsByTag('input', true, { limit: 0 })
).resolves.toEqual([])
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('limit')).toBe('500')
})
it('throws before fetching when the pagination signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
assetService.getAllAssetsByTag('input', true, {
signal: controller.signal
})
).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
@@ -1095,75 +703,6 @@ describe(assetService.getAllAssetsByTag, () => {
})
})
describe(assetService.createAssetExport, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('posts export options and returns the export task', async () => {
const task = { task_id: 'export-1', status: 'created', message: 'queued' }
fetchApiMock.mockResolvedValueOnce(buildResponse(task))
await expect(
assetService.createAssetExport({
asset_ids: ['asset-1'],
include_previews: true
})
).resolves.toEqual(task)
expect(fetchApiMock).toHaveBeenCalledWith(
'/assets/export',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
asset_ids: ['asset-1'],
include_previews: true
})
})
)
})
it('throws when creating an export fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 503 })
)
await expect(
assetService.createAssetExport({ asset_ids: ['asset-1'] })
).rejects.toThrow('Failed to create asset export: 503')
})
})
describe(assetService.getExportDownloadUrl, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the export download URL', async () => {
const download = {
url: 'https://example.com/export.zip',
expires_at: '2026-07-01T00:00:00Z'
}
fetchApiMock.mockResolvedValueOnce(buildResponse(download))
await expect(
assetService.getExportDownloadUrl('export.zip')
).resolves.toEqual(download)
expect(fetchApiMock).toHaveBeenCalledWith('/assets/exports/export.zip')
})
it('throws when export download URL lookup fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 404 })
)
await expect(
assetService.getExportDownloadUrl('missing.zip')
).rejects.toThrow('Failed to get export download URL: 404')
})
})
describe(assetService.getInputAssetsIncludingPublic, () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1190,17 +729,6 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
expect(params.get('limit')).toBe('500')
})
it('throws before starting a shared request when the caller signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
assetService.getInputAssetsIncludingPublic(controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('fetches fresh input assets after explicit invalidation', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]

View File

@@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetType } from '@/platform/assets/utils/assetTypeUtil'
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'image.png',
preview_url: '',
tags: [],
created_at: '',
updated_at: '',
size: 0,
mime_type: 'image/png',
user_metadata: {},
...overrides
} as AssetItem
}
describe('getAssetType', () => {
it('prefers the preview URL type over tags', () => {
expect(
getAssetType(
asset({
preview_url: '/api/view?filename=image.png&type=temp',
tags: ['output']
})
)
).toBe('temp')
})
it('falls back to tags and then the supplied default type', () => {
expect(getAssetType(asset({ tags: ['input'] }))).toBe('input')
expect(getAssetType(asset(), 'input')).toBe('input')
})
})

View File

@@ -1,62 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
const { apiURL } = vi.hoisted(() => ({
apiURL: vi.fn((path: string) => `https://comfy.local${path}`)
}))
vi.mock('@/scripts/api', () => ({
api: { apiURL }
}))
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'folder image.png',
preview_url: '',
tags: ['output'],
created_at: '',
updated_at: '',
size: 0,
mime_type: 'image/png',
user_metadata: {},
...overrides
} as AssetItem
}
beforeEach(() => {
apiURL.mockClear()
})
describe('getAssetUrl', () => {
it('builds encoded view URLs with type and subfolder', () => {
const url = getAssetUrl(
asset({
user_metadata: { subfolder: 'nested/path' }
})
)
expect(apiURL).toHaveBeenCalledWith(
'/view?filename=folder+image.png&type=output&subfolder=nested%2Fpath'
)
expect(url).toBe(
'https://comfy.local/view?filename=folder+image.png&type=output&subfolder=nested%2Fpath'
)
})
it('uses preview URL type and omits empty subfolders', () => {
getAssetUrl(
asset({
preview_url: '/api/view?filename=image.png&type=temp',
tags: ['output'],
user_metadata: { subfolder: '' }
})
)
expect(apiURL).toHaveBeenCalledWith(
'/view?filename=folder+image.png&type=temp'
)
})
})

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -29,8 +28,6 @@ interface HostAssetWidget extends IBaseWidget<
node: LGraphNode
}
type AssetWidget = IBaseWidget<string | undefined, 'asset', IWidgetAssetOptions>
type OnWidgetChanged = NonNullable<LGraphNode['onWidgetChanged']>
function checkpointAsset(name: string): AssetItem {
@@ -169,118 +166,4 @@ describe('createAssetWidget', () => {
)
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('falls back to widget name and empty current value for cloned widgets', async () => {
const { node } = createAssetWidgetNode()
const sourceWidget = createAssetWidget({
node,
widgetName: 'lora_name',
nodeTypeForBrowser: 'LoraLoader'
})
assertAssetOptions(sourceWidget.options)
const clonedWidget: AssetWidget = {
type: 'asset',
name: 'lora_name',
value: undefined,
options: sourceWidget.options,
y: 0
}
await sourceWidget.options.openModal(clonedWidget)
expect(firstShowOptions()).toMatchObject({
nodeType: 'LoraLoader',
inputName: 'lora_name',
currentValue: ''
})
})
it('rejects malformed asset selections', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { node } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
firstShowOptions().onAssetSelected?.(
fromPartial({ id: 'asset-without-name' })
)
expect(widget.value).toBe('fake_model.safetensors')
expect(captureCanvasState).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('rejects invalid asset filenames', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { node } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
firstShowOptions().onAssetSelected?.(checkpointAsset('../bad.safetensors'))
expect(widget.value).toBe('fake_model.safetensors')
expect(captureCanvasState).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('updates ownerless cloned widgets without node callbacks', async () => {
const { node, onWidgetChanged } = createAssetWidgetNode()
const sourceWidget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(sourceWidget.options)
const callback = vi.fn<NonNullable<IBaseWidget['callback']>>()
const clonedWidget: AssetWidget = {
type: 'asset',
name: 'ckpt_name',
value: 'fake_model.safetensors',
callback,
options: sourceWidget.options,
y: 0
}
await sourceWidget.options.openModal(clonedWidget)
firstShowOptions().onAssetSelected?.(
checkpointAsset('real_model.safetensors')
)
expect(clonedWidget.value).toBe('real_model.safetensors')
expect(callback).toHaveBeenCalledWith('real_model.safetensors')
expect(onWidgetChanged).not.toHaveBeenCalled()
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('does not capture canvas state when the selection is unchanged', async () => {
const { node } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
firstShowOptions().onAssetSelected?.(
checkpointAsset('fake_model.safetensors')
)
expect(widget.value).toBe('fake_model.safetensors')
expect(captureCanvasState).not.toHaveBeenCalled()
})
})

View File

@@ -51,7 +51,7 @@
<p class="mb-5 text-center text-sm text-gray-600">
{{ $t('cloudOnboarding.authTimeout.helpText') }}
<a
href="https://support.comfy.org"
:href="supportUrl"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
@@ -75,6 +75,7 @@ import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
interface Props {
errorMessage?: string
@@ -86,6 +87,10 @@ const router = useRouter()
const { logout } = useAuthActions()
const showTechnicalDetails = ref(false)
const supportUrl = buildSupportUrl(SupportForm.Question, {
productArea: 'Cloud Onboarding'
})
const handleRestart = async () => {
await logout()
await router.replace({ name: 'cloud-login' })

View File

@@ -113,7 +113,7 @@
>
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
:href="supportUrl"
class="cursor-pointer text-azure-600 no-underline"
target="_blank"
rel="noopener noreferrer"
@@ -136,6 +136,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
@@ -162,6 +163,10 @@ const { onAuthSuccess } = usePostAuthRedirect({
defaultRedirect: () => ({ path: '/', query: route.query })
})
const supportUrl = buildSupportUrl(SupportForm.Question, {
productArea: 'Cloud Onboarding'
})
const navigateToLogin = async () => {
await router.push({ name: 'cloud-login', query: route.query })
}

View File

@@ -17,7 +17,7 @@
{{ t('auth.login.privacyLink') }}
</a>
<a
href="https://support.comfy.org"
:href="supportUrl"
class="cursor-pointer text-sm text-gray-600 no-underline"
target="_blank"
rel="noopener noreferrer"
@@ -30,5 +30,11 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
const { t } = useI18n()
const supportUrl = buildSupportUrl(SupportForm.Question, {
productArea: 'Cloud Onboarding'
})
</script>

View File

@@ -159,8 +159,9 @@ import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeB
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
const { onClose, reason, onChooseTeam } = defineProps<{
@@ -188,7 +189,7 @@ const formattedMonthlyPrice = new Intl.NumberFormat(
maximumFractionDigits: 0
}
).format(MONTHLY_SUBSCRIPTION_PRICE)
const commandStore = useCommandStore()
const { openSupport } = useSupportContext()
const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
@@ -219,13 +220,13 @@ const handleClose = () => {
onClose()
}
const handleContactUs = async () => {
const handleContactUs = () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
await commandStore.execute('Comfy.ContactSupport')
openSupport(SupportForm.Billing, { productArea: 'Billing' })
}
const handleViewEnterprise = () => {

View File

@@ -6,7 +6,7 @@ const mockBillingFetchBalance = vi.fn()
const mockAuthFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
const mockOpenSupport = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
@@ -32,13 +32,14 @@ vi.mock('@/services/dialogService', () => ({
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute
vi.mock('@/platform/support/useSupportContext', () => ({
useSupportContext: () => ({
openSupport: mockOpenSupport
})
}))
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
// mockIsCloud drives both the `isCloud` build flag (which gates the telemetry
// call) and useTelemetry() (which returns null in OSS, a dispatcher in cloud).
const {
mockIsCloud,
mockTrackHelpResourceClicked,
@@ -59,6 +60,14 @@ vi.mock('@/platform/telemetry', () => ({
: null
}))
vi.mock('@/platform/distribution/types', () => ({
isDesktop: false,
isNightly: false,
get isCloud() {
return mockIsCloud.value
}
}))
// Mock window.open
const mockOpen = vi.fn()
Object.defineProperty(window, 'open', {
@@ -84,24 +93,24 @@ describe('useSubscriptionActions', () => {
})
describe('handleMessageSupport', () => {
it('should execute support command and manage loading state', async () => {
it('opens the Pylon billing form and resets loading state', () => {
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
expect(isLoadingSupport.value).toBe(false)
const promise = handleMessageSupport()
expect(isLoadingSupport.value).toBe(true)
handleMessageSupport()
await promise
expect(mockExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mockOpenSupport).toHaveBeenCalledWith('billing-refund-issue', {
productArea: 'Billing'
})
expect(isLoadingSupport.value).toBe(false)
})
it('tracks help-resource telemetry when messaging support in cloud', async () => {
it('tracks help-resource telemetry when messaging support in cloud', () => {
const { handleMessageSupport } = useSubscriptionActions()
await handleMessageSupport()
handleMessageSupport()
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
@@ -110,21 +119,23 @@ describe('useSubscriptionActions', () => {
})
})
it('does not fire telemetry when messaging support in OSS builds', async () => {
it('does not fire telemetry when messaging support in OSS builds', () => {
mockIsCloud.value = false
const { handleMessageSupport } = useSubscriptionActions()
await handleMessageSupport()
handleMessageSupport()
expect(mockTrackHelpResourceClicked).not.toHaveBeenCalled()
})
it('should handle errors gracefully', async () => {
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
it('handles errors gracefully', () => {
mockOpenSupport.mockImplementationOnce(() => {
throw new Error('open failed')
})
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
await handleMessageSupport()
handleMessageSupport()
expect(isLoadingSupport.value).toBe(false)
})
})

View File

@@ -1,16 +1,18 @@
import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
/**
* Composable for handling subscription panel actions and loading states
*/
export function useSubscriptionActions() {
const dialogService = useDialogService()
const commandStore = useCommandStore()
const { openSupport } = useSupportContext()
const telemetry = useTelemetry()
const { fetchBalance, fetchStatus } = useBillingContext()
@@ -27,15 +29,17 @@ export function useSubscriptionActions() {
void dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = async () => {
const handleMessageSupport = () => {
try {
isLoadingSupport.value = true
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
await commandStore.execute('Comfy.ContactSupport')
if (isCloud) {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
}
openSupport(SupportForm.Billing, { productArea: 'Billing' })
} catch (error) {
console.error('[useSubscriptionActions] Error contacting support:', error)
} finally {

View File

@@ -50,3 +50,184 @@ describe('buildFeedbackTypeformUrl', () => {
expect(url.hash).toBe('#distribution=ccloud&source=topbar')
})
})
describe('buildSupportUrl', () => {
const ORIGINAL_UA = navigator.userAgent
beforeEach(() => {
distribution.isCloud = false
distribution.isNightly = false
Object.defineProperty(navigator, 'userAgent', {
value: ORIGINAL_UA,
configurable: true
})
})
function setUserAgent(value: string) {
Object.defineProperty(navigator, 'userAgent', {
value,
configurable: true
})
}
async function importModule() {
vi.resetModules()
return import('./config')
}
it('defaults to the question form when no form is provided', async () => {
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
expect(url.pathname).toBe('/forms/question')
})
it('routes to the requested form slug', async () => {
const { buildSupportUrl, SupportForm } = await importModule()
const url = new URL(buildSupportUrl(SupportForm.Billing))
expect(url.pathname).toBe('/forms/billing-refund-issue')
})
it('encodes spaces as %20 (not "+") in the query string', async () => {
setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0'
)
const { buildSupportUrl, SupportForm } = await importModule()
const raw = buildSupportUrl(SupportForm.Bug, {
userEmail: 'user@example.com',
os: 'macOS 14.5'
})
expect(raw).toContain('comfy_os=macOS%2014.5')
expect(raw).not.toContain('+')
})
it('omits fields with empty or null values', async () => {
const { buildSupportUrl, SupportForm } = await importModule()
const url = new URL(
buildSupportUrl(SupportForm.Question, {
userEmail: '',
userId: null,
os: undefined,
version: '1.45.0'
})
)
expect(url.searchParams.has('email')).toBe(false)
expect(url.searchParams.has('comfy_cloud_user_id')).toBe(false)
expect(url.searchParams.has('comfy_os')).toBe(false)
expect(url.searchParams.get('comfy_version')).toBe('1.45.0')
})
it('tags Cloud builds with comfy_environment=ccloud', async () => {
distribution.isCloud = true
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.searchParams.get('comfy_environment')).toBe('ccloud')
})
it('tags Nightly builds with comfy_environment=oss-nightly', async () => {
distribution.isNightly = true
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.searchParams.get('comfy_environment')).toBe('oss-nightly')
})
it('detects Chrome from the user agent', async () => {
setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
)
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.searchParams.get('browser')).toBe('Chrome 131')
})
it('detects Firefox from the user agent', async () => {
setUserAgent(
'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0'
)
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.searchParams.get('browser')).toBe('Firefox 121')
})
it('detects Edge before falling through to Chrome', async () => {
setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0'
)
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.searchParams.get('browser')).toBe('Edge 131')
})
it('forwards a product area override to the prefill', async () => {
const { buildSupportUrl, SupportForm } = await importModule()
const url = new URL(
buildSupportUrl(SupportForm.Billing, { productArea: 'Billing' })
)
expect(url.searchParams.get('product_area')).toBe('Billing')
})
})
describe('normalizeOsName', () => {
const ORIGINAL_UA = navigator.userAgent
beforeEach(() => {
Object.defineProperty(navigator, 'userAgent', {
value: ORIGINAL_UA,
configurable: true
})
})
function setUserAgent(value: string) {
Object.defineProperty(navigator, 'userAgent', {
value,
configurable: true
})
}
async function importModule() {
vi.resetModules()
return import('./config')
}
it('promotes "darwin" to the UA-detected macOS version', async () => {
setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5_0) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36'
)
const { normalizeOsName } = await importModule()
expect(normalizeOsName('darwin')).toBe('macOS 14.5.0')
})
it('promotes "win32" to the UA-detected Windows version', async () => {
setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36'
)
const { normalizeOsName } = await importModule()
expect(normalizeOsName('win32')).toBe('Windows 10/11')
})
it('promotes "linux" to "Linux" when UA reports Linux', async () => {
setUserAgent('Mozilla/5.0 (X11; Linux x86_64) Firefox/121.0')
const { normalizeOsName } = await importModule()
expect(normalizeOsName('linux')).toBe('Linux')
})
it('keeps a descriptive value untouched', async () => {
const { normalizeOsName } = await importModule()
expect(normalizeOsName('Ubuntu 22.04')).toBe('Ubuntu 22.04')
})
it('falls back to UA detection when the input is empty', async () => {
setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0 Safari/537.36'
)
const { normalizeOsName } = await importModule()
expect(normalizeOsName(null)).toBe('macOS 10.15.7')
expect(normalizeOsName('')).toBe('macOS 10.15.7')
})
it('falls back to the kernel name when UA detection cannot resolve', async () => {
setUserAgent('SomeWeirdBot/1.0')
const { normalizeOsName } = await importModule()
expect(normalizeOsName('darwin')).toBe('darwin')
})
})

View File

@@ -1,70 +1,189 @@
import { isCloud, isNightly } from '@/platform/distribution/types'
/**
* Zendesk ticket form field IDs.
* Slug of a Pylon form under https://comfy-org.portal.usepylon.com/forms/.
* The form slug determines which ticket form opens and which fields are shown.
*/
const ZENDESK_FIELDS = {
/** Distribution tag (cloud vs OSS) */
DISTRIBUTION: 'tf_42243568391700',
/** User email (anonymous requester) */
ANONYMOUS_EMAIL: 'tf_anonymous_requester_email',
/** User email (authenticated) */
EMAIL: 'tf_40029135130388',
/** User ID */
USER_ID: 'tf_42515251051412'
export const SupportForm = {
Billing: 'billing-refund-issue',
Bug: 'report-a-bug',
FeatureRequest: 'feature-request',
PartnerNode: 'partner-node-issue',
Question: 'question'
} as const
export type SupportForm = (typeof SupportForm)[keyof typeof SupportForm]
/**
* Gets the distribution identifier for tracking.
* Helps distinguish feedback from different build types.
* Pylon custom-field slugs (URL keys) configured for the comfy-org workspace.
* Pylon prefill uses the slug — not the field UUID — as the URL key.
*/
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
const PYLON_FIELDS = {
EMAIL: 'email',
BROWSER: 'browser',
COMFY_CLOUD_USER_ID: 'comfy_cloud_user_id',
COMFY_ENVIRONMENT: 'comfy_environment',
COMFY_OS: 'comfy_os',
COMFY_VERSION: 'comfy_version',
PRODUCT_AREA: 'product_area'
} as const
const PYLON_FORMS_BASE_URL = 'https://comfy-org.portal.usepylon.com/forms/'
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
/**
* Build environment tag for distinguishing tickets by build type.
*/
function getEnvironment(): 'ccloud' | 'oss-nightly' | 'oss' {
if (isCloud) return 'ccloud'
if (isNightly) return 'oss-nightly'
return 'oss'
}
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
/**
* Builds the feedback Typeform URL tagged with the current build distribution
* Builds the feedback Typeform URL tagged with the current build environment
* and the UI source that opened it. Tags are passed via the URL fragment
* (Typeform's hidden-field convention) so survey responses can be segmented
* by distribution (cloud / oss-nightly / oss) and entry point.
* by environment (cloud / oss-nightly / oss) and entry point.
*/
export function buildFeedbackTypeformUrl(
source: 'topbar' | 'action-bar' | 'help-center'
): string {
const params = new URLSearchParams({
distribution: getDistribution(),
distribution: getEnvironment(),
source
})
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
}
/**
* Builds the support URL with optional user information for pre-filling.
* Users without login information will still get a valid support URL without pre-fill.
*
* @param params - User information to pre-fill in the support form
* @returns Complete Zendesk support URL with query parameters
*/
export function buildSupportUrl(params?: {
export interface SupportPrefill {
/** Authenticated user's email (for Cloud / API-key users). */
userEmail?: string | null
/** Cloud user id, when available. */
userId?: string | null
}): string {
const searchParams = new URLSearchParams({
[ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
})
if (params?.userEmail) {
searchParams.append(ZENDESK_FIELDS.ANONYMOUS_EMAIL, params.userEmail)
searchParams.append(ZENDESK_FIELDS.EMAIL, params.userEmail)
}
if (params?.userId) {
searchParams.append(ZENDESK_FIELDS.USER_ID, params.userId)
}
return `${SUPPORT_BASE_URL}?${searchParams.toString()}`
/** Operating system string (e.g. "macOS 14.5"). */
os?: string | null
/** ComfyUI frontend version. */
version?: string | null
/** Product area this ticket belongs to (e.g. "Billing", "Cloud"). */
productArea?: string | null
}
/**
* Encode a single `slug=value` pair. Skips empty values so the resulting URL
* stays clean. We use `encodeURIComponent` (not `URLSearchParams`) so spaces
* become `%20` rather than `+`, matching the Pylon prefill spec.
*/
function encodePair(
slug: string,
value: string | null | undefined
): string | null {
if (value === null || value === undefined || value === '') return null
return `${encodeURIComponent(slug)}=${encodeURIComponent(value)}`
}
function detectBrowser(): string | null {
if (typeof navigator === 'undefined') return null
const ua = navigator.userAgent
// Order matters: Edge / Opera identify themselves as Chrome too.
const matchers: { name: string; pattern: RegExp }[] = [
{ name: 'Edge', pattern: /Edg\/([\d.]+)/ },
{ name: 'Opera', pattern: /OPR\/([\d.]+)/ },
{ name: 'Chrome', pattern: /Chrome\/([\d.]+)/ },
{ name: 'Firefox', pattern: /Firefox\/([\d.]+)/ },
{ name: 'Safari', pattern: /Version\/([\d.]+).*Safari/ }
]
for (const { name, pattern } of matchers) {
const match = ua.match(pattern)
if (match) return `${name} ${match[1].split('.')[0]}`
}
return null
}
/**
* Derive a user-friendly OS string from the browser. Preferred over backend
* platform names like `darwin` / `win32` because those are kernel identifiers,
* not what users (or support agents) recognize. Modern browsers freeze the
* macOS / Windows minor version in the UA string, so we only report the
* family — that's still more useful than `darwin`.
*/
export function detectOS(): string | null {
if (typeof navigator === 'undefined') return null
const ua = navigator.userAgent
if (/iPad|iPhone|iPod/.test(ua)) {
const iOS = ua.match(/OS (\d+)[._](\d+)(?:[._](\d+))?/)
return iOS ? `iOS ${iOS[1]}.${iOS[2]}${iOS[3] ? `.${iOS[3]}` : ''}` : 'iOS'
}
if (/Android/.test(ua)) {
const android = ua.match(/Android (\d+(?:\.\d+)*)/)
return android ? `Android ${android[1]}` : 'Android'
}
if (/Mac OS X|Macintosh/.test(ua)) {
const mac = ua.match(/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/)
if (!mac) return 'macOS'
return `macOS ${mac[1]}.${mac[2]}${mac[3] ? `.${mac[3]}` : ''}`
}
if (/Windows NT/.test(ua)) {
const win = ua.match(/Windows NT (\d+\.\d+)/)
const winMap: Record<string, string> = {
'10.0': 'Windows 10/11',
'6.3': 'Windows 8.1',
'6.2': 'Windows 8',
'6.1': 'Windows 7'
}
return win ? (winMap[win[1]] ?? `Windows NT ${win[1]}`) : 'Windows'
}
if (/CrOS/.test(ua)) return 'ChromeOS'
if (/Linux/.test(ua)) return 'Linux'
return null
}
/**
* Backend (`systemStats.system.os`) reports the Python platform identifier
* for OSS / Desktop, which is the kernel name (`darwin`, `linux`, `win32`).
* Promote those to the UA-detected version so the Pylon ticket shows
* "macOS 14.5" instead of "darwin".
*/
export function normalizeOsName(
rawOs: string | null | undefined
): string | null {
const uaOs = detectOS()
if (!rawOs) return uaOs
const lower = rawOs.toLowerCase().trim()
if (lower === 'darwin' || lower === 'linux' || lower === 'win32') {
return uaOs ?? rawOs
}
return rawOs
}
/**
* Builds the Pylon prefill URL for a given form, omitting empty fields.
* Users without prefill data still get a valid URL that opens the same form —
* Pylon will collect those values from the user manually.
*
* @param form - Which Pylon form to open
* @param prefill - Field values to pre-populate
* @returns Complete Pylon form URL
*/
export function buildSupportUrl(
form: SupportForm = SupportForm.Question,
prefill: SupportPrefill = {}
): string {
const pairs: string[] = []
const push = (slug: string, value: string | null | undefined) => {
const pair = encodePair(slug, value)
if (pair) pairs.push(pair)
}
push(PYLON_FIELDS.EMAIL, prefill.userEmail)
push(PYLON_FIELDS.COMFY_CLOUD_USER_ID, prefill.userId)
push(PYLON_FIELDS.COMFY_ENVIRONMENT, getEnvironment())
push(PYLON_FIELDS.COMFY_VERSION, prefill.version)
push(PYLON_FIELDS.COMFY_OS, prefill.os)
push(PYLON_FIELDS.BROWSER, detectBrowser())
push(PYLON_FIELDS.PRODUCT_AREA, prefill.productArea)
const query = pairs.join('&')
return `${PYLON_FORMS_BASE_URL}${form}${query ? `?${query}` : ''}`
}

View File

@@ -0,0 +1,52 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
SupportForm,
buildSupportUrl,
normalizeOsName
} from '@/platform/support/config'
import type { SupportPrefill } from '@/platform/support/config'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
/**
* Resolves Pylon prefill data from the current user session + system stats and
* exposes a single `openSupport(form, extras?)` action that opens the best-fit
* Pylon form in a new tab.
*
* Resolution is deferred until `openSupport`/`buildPrefill` is actually called
* — call sites that never invoke them don't pay the cost of (or fail because
* of) booting Firebase auth at component setup time.
*/
export function useSupportContext() {
const buildPrefill = (extra?: Partial<SupportPrefill>): SupportPrefill => {
const { userEmail, resolvedUserInfo } = useCurrentUser()
const systemStatsStore = useSystemStatsStore()
return {
userEmail: userEmail.value ?? null,
userId: resolvedUserInfo.value?.id ?? null,
os: normalizeOsName(systemStatsStore.systemStats?.system?.os),
version: __COMFYUI_FRONTEND_VERSION__,
...extra
}
}
/**
* Open a Pylon support form pre-filled with the user's context. Any field
* we can't resolve is omitted from the URL — the form still opens.
*
* @param form - Which Pylon form best matches the entry-point. Defaults to
* the generic "Question" form.
* @param extra - Per-callsite overrides (e.g. `productArea: 'Billing'`).
*/
const openSupport = (
form: SupportForm = SupportForm.Question,
extra?: Partial<SupportPrefill>
): void => {
const url = buildSupportUrl(form, buildPrefill(extra))
window.open(url, '_blank', 'noopener,noreferrer')
}
return {
buildPrefill,
openSupport
}
}

View File

@@ -9,7 +9,8 @@ import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useExternalLink } from '@/composables/useExternalLink'
import { resolveRunErrorMessage } from '@/platform/errorCatalog/errorMessageResolver'
import { buildSupportUrl } from '@/platform/support/config'
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -26,7 +27,11 @@ const { copyToClipboard } = useCopyToClipboard()
const guideUrl = buildDocsUrl('troubleshooting/overview', {
includeLocale: true
})
const supportUrl = buildSupportUrl()
const { buildPrefill } = useSupportContext()
const supportUrl = buildSupportUrl(
SupportForm.Bug,
buildPrefill({ productArea: 'Linear Mode' })
)
const inputNodeIds = computed(() => {
const ids = new Set()

View File

@@ -1,286 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface AxiosLikeError extends Error {
isAxiosError: true
response?: {
status: number
data?: {
message?: string
}
}
}
const mockClient = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn()
}))
const mockAxios = vi.hoisted(() => ({
create: vi.fn(() => mockClient),
isAxiosError: vi.fn(
(error: unknown): error is AxiosLikeError =>
typeof error === 'object' &&
error !== null &&
'isAxiosError' in error &&
error.isAxiosError === true
)
}))
vi.mock('axios', () => ({
default: mockAxios
}))
import { useComfyRegistryService } from './comfyRegistryService'
function response<T>(data: T) {
return { data }
}
function axiosError(
message: string,
responseData?: AxiosLikeError['response']
): AxiosLikeError {
const error = new Error(message) as AxiosLikeError
error.isAxiosError = true
if (responseData) error.response = responseData
return error
}
describe('useComfyRegistryService', () => {
beforeEach(() => {
mockClient.get.mockReset()
mockClient.post.mockReset()
mockAxios.isAxiosError.mockClear()
})
it('configures the registry axios client with repeated query params', () => {
expect(mockAxios.create).toHaveBeenCalledWith({
baseURL: 'https://api.comfy.org',
headers: {
'Content-Type': 'application/json'
},
paramsSerializer: {
indexes: null
}
})
})
it('returns response data and clears loading state for successful requests', async () => {
mockClient.get.mockResolvedValueOnce(response({ nodes: [] }))
const service = useComfyRegistryService()
const result = await service.search({ search: 'manager' })
expect(result).toEqual({ nodes: [] })
expect(mockClient.get).toHaveBeenCalledWith('/nodes/search', {
params: { search: 'manager' },
signal: undefined
})
expect(service.error.value).toBeNull()
expect(service.isLoading.value).toBe(false)
})
it('skips node definition requests when pack id or version is missing', async () => {
const service = useComfyRegistryService()
await expect(
service.getNodeDefs({ packId: '', version: '1.0.0' })
).resolves.toBeNull()
await expect(
service.getNodeDefs({ packId: 'pack', version: '' })
).resolves.toBeNull()
expect(mockClient.get).not.toHaveBeenCalled()
})
it('passes query params and abort signals through node definition requests', async () => {
const signal = new AbortController().signal
mockClient.get.mockResolvedValueOnce(response([{ name: 'KSampler' }]))
const service = useComfyRegistryService()
const result = await service.getNodeDefs(
{ packId: 'pack', version: '1.0.0', page: 2 },
signal
)
expect(result).toEqual([{ name: 'KSampler' }])
expect(mockClient.get).toHaveBeenCalledWith(
'/nodes/pack/versions/1.0.0/comfy-nodes',
{
params: { page: 2 },
signal
}
)
})
it('routes publisher, pack, and review methods to their registry endpoints', async () => {
mockClient.get
.mockResolvedValueOnce(response({ id: 'publisher' }))
.mockResolvedValueOnce(response([{ id: 'pack' }]))
.mockResolvedValueOnce(response([{ version: '1.0.0' }]))
.mockResolvedValueOnce(response({ id: 'version' }))
.mockResolvedValueOnce(response({ id: 'pack' }))
.mockResolvedValueOnce(response({ id: 'pack' }))
.mockResolvedValueOnce(response({ id: 'pack' }))
mockClient.post
.mockResolvedValueOnce(response({ id: 'reviewed' }))
.mockResolvedValueOnce(response({ node_versions: [] }))
const service = useComfyRegistryService()
const signal = new AbortController().signal
await expect(
service.getPublisherById('publisher', signal)
).resolves.toEqual({ id: 'publisher' })
await expect(
service.listPacksForPublisher('publisher', true, signal)
).resolves.toEqual([{ id: 'pack' }])
await expect(
service.getPackVersions(
'pack',
{ statuses: ['NodeVersionStatusActive'] },
signal
)
).resolves.toEqual([{ version: '1.0.0' }])
await expect(
service.getPackByVersion('pack', 'version', signal)
).resolves.toEqual({ id: 'version' })
await expect(service.getPackById('pack', signal)).resolves.toEqual({
id: 'pack'
})
await expect(
service.inferPackFromNodeName('KSampler', signal)
).resolves.toEqual({ id: 'pack' })
await expect(service.listAllPacks({ page: 1 }, signal)).resolves.toEqual({
id: 'pack'
})
await expect(service.postPackReview('pack', 5, signal)).resolves.toEqual({
id: 'reviewed'
})
await expect(
service.getBulkNodeVersions(
[{ node_id: 'pack', version: '1.0.0' }],
signal
)
).resolves.toEqual({ node_versions: [] })
expect(mockClient.get).toHaveBeenNthCalledWith(1, '/publishers/publisher', {
signal
})
expect(mockClient.get).toHaveBeenNthCalledWith(
2,
'/publishers/publisher/nodes',
{
params: { include_banned: true },
signal
}
)
expect(mockClient.get).toHaveBeenNthCalledWith(3, '/nodes/pack/versions', {
params: { statuses: ['NodeVersionStatusActive'] },
signal
})
expect(mockClient.get).toHaveBeenNthCalledWith(
4,
'/nodes/pack/versions/version',
{ signal }
)
expect(mockClient.get).toHaveBeenNthCalledWith(5, '/nodes/pack', {
signal
})
expect(mockClient.get).toHaveBeenNthCalledWith(
6,
'/comfy-nodes/KSampler/node',
{ signal }
)
expect(mockClient.get).toHaveBeenNthCalledWith(7, '/nodes', {
params: { page: 1 },
signal
})
expect(mockClient.post).toHaveBeenNthCalledWith(
1,
'/nodes/pack/reviews',
null,
{ params: { star: 5 }, signal }
)
expect(mockClient.post).toHaveBeenNthCalledWith(
2,
'/bulk/nodes/versions',
{ node_versions: [{ node_id: 'pack', version: '1.0.0' }] },
{ signal }
)
})
it('omits include_banned when listing publisher packs without banned packs', async () => {
mockClient.get.mockResolvedValueOnce(response([]))
const service = useComfyRegistryService()
await service.listPacksForPublisher('publisher', false)
expect(mockClient.get).toHaveBeenCalledWith('/publishers/publisher/nodes', {
params: undefined,
signal: undefined
})
})
it.for([
{ status: 400, expected: 'Bad request: Invalid input' },
{ status: 401, expected: 'Unauthorized: Authentication required' },
{ status: 403, expected: 'Forbidden: Access denied' },
{ status: 404, expected: 'Not found: Resource not found' },
{ status: 409, expected: 'Conflict: Resource conflict' },
{ status: 500, expected: 'Server error: Internal server error' },
{ status: 418, expected: 'Failed to perform search: teapot' }
])(
'normalizes axios response status $status',
async ({ status, expected }) => {
mockClient.get.mockRejectedValueOnce(
axiosError('Request failed', {
status,
data: status === 418 ? { message: 'teapot' } : {}
})
)
const service = useComfyRegistryService()
await expect(service.search()).resolves.toBeNull()
expect(service.error.value).toBe(expected)
expect(service.isLoading.value).toBe(false)
}
)
it('uses route-specific errors before generic status messages', async () => {
mockClient.get.mockRejectedValueOnce(
axiosError('Request failed', {
status: 404,
data: { message: 'ignored' }
})
)
const service = useComfyRegistryService()
await expect(service.getPackById('missing')).resolves.toBeNull()
expect(service.error.value).toBe(
'Pack not found: The pack with ID missing does not exist'
)
})
it('normalizes network, thrown Error, unknown, and abort failures', async () => {
const service = useComfyRegistryService()
mockClient.get.mockRejectedValueOnce(axiosError('Network down'))
await expect(service.search()).resolves.toBeNull()
expect(service.error.value).toBe('Failed to perform search: Network down')
mockClient.get.mockRejectedValueOnce(new Error('boom'))
await expect(service.search()).resolves.toBeNull()
expect(service.error.value).toBe('Failed to perform search: boom')
mockClient.get.mockRejectedValueOnce('bad')
await expect(service.search()).resolves.toBeNull()
expect(service.error.value).toBe(
'Failed to perform search: Unknown error occurred'
)
mockClient.get.mockRejectedValueOnce(new DOMException('', 'AbortError'))
await expect(service.search()).resolves.toBeNull()
expect(service.error.value).toBeNull()
})
})

View File

@@ -234,54 +234,6 @@ describe('useRegistrySearchGateway', () => {
const gateway = useRegistrySearchGateway()
expect(gateway).toBeDefined()
})
it('waits for the circuit breaker timeout before retrying a failed provider', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'))
vi.mocked(useAlgoliaSearchProvider).mockImplementation(() => {
throw new Error('Algolia init failed')
})
const registryResult = {
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
querySuggestions: []
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Registry failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
for (let attempt = 0; attempt < 3; attempt++) {
await expect(
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
).rejects.toThrow('All search providers failed')
}
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(3)
await expect(
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
).rejects.toThrow('All search providers failed')
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(3)
vi.setSystemTime(new Date('2024-01-01T00:01:01Z'))
mockRegistryProvider.searchPacks.mockResolvedValueOnce(registryResult)
await expect(
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
).resolves.toBe(registryResult)
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(4)
})
})
describe('Cache management', () => {

View File

@@ -126,19 +126,6 @@ describe('useAssetDownloadStore', () => {
})
})
it('keeps the first placeholder when the same task is tracked twice', () => {
const store = useAssetDownloadStore()
store.trackDownload('task-123', 'checkpoints', 'first.safetensors')
store.trackDownload('task-123', 'loras', 'second.safetensors')
expect(store.downloadList).toHaveLength(1)
expect(store.downloadList[0]).toMatchObject({
modelType: 'checkpoints',
assetName: 'first.safetensors'
})
})
it('handles out-of-order messages where completed arrives before progress', () => {
const store = useAssetDownloadStore()
@@ -192,19 +179,6 @@ describe('useAssetDownloadStore', () => {
expect(store.finishedDownloads[0].status).toBe('completed')
})
it('skips polling when active downloads have fresh progress', async () => {
const store = useAssetDownloadStore()
dispatch(createDownloadMessage({ status: 'running' }))
await vi.advanceTimersByTimeAsync(9_999)
dispatch(createDownloadMessage({ status: 'running', progress: 75 }))
await vi.advanceTimersByTimeAsync(1)
expect(taskService.getTask).not.toHaveBeenCalled()
expect(store.activeDownloads).toHaveLength(1)
expect(store.activeDownloads[0].progress).toBe(75)
})
it('polls and marks failed downloads', async () => {
const store = useAssetDownloadStore()
@@ -337,22 +311,5 @@ describe('useAssetDownloadStore', () => {
expect(store.sessionDownloadCount).toBe(0)
expect(store.isDownloadedThisSession('asset-456')).toBe(false)
})
it('does not acknowledge unrelated completed downloads', () => {
const store = useAssetDownloadStore()
dispatch(
createDownloadMessage({
status: 'completed',
progress: 100,
asset_id: 'asset-456'
})
)
store.acknowledgeAsset('other-asset')
expect(store.sessionDownloadCount).toBe(1)
expect(store.isDownloadedThisSession('asset-456')).toBe(true)
})
})
})

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
@@ -12,7 +11,6 @@ import type {
} from '@/platform/assets/schemas/assetSchema'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { assetService } from '@/platform/assets/services/assetService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
// Mock the api module
vi.mock('@/scripts/api', () => ({
@@ -98,10 +96,6 @@ const mockOutputOverrides = vi.hoisted(() => ({
value: null as MockOutput[] | null
}))
const mockAssetMapperOptions = vi.hoisted(() => ({
omitCreatedAtForIds: new Set<string>()
}))
// Mock TaskItemImpl
const PREVIEWABLE_MEDIA_TYPES = new Set(['images', 'video', 'audio'])
@@ -175,14 +169,11 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
})),
mapTaskOutputToAssetItem: vi.fn((task, output) => {
const index = parseInt(task.jobId.split('_')[1]) || 0
const createdAt = new Date(Date.now() - index * 1000).toISOString()
return {
id: task.jobId,
name: output.filename,
size: 0,
...(!mockAssetMapperOptions.omitCreatedAtForIds.has(task.jobId) && {
created_at: createdAt
}),
created_at: new Date(Date.now() - index * 1000).toISOString(),
tags: ['output'],
preview_url: output.url,
user_metadata: {}
@@ -214,7 +205,6 @@ describe('assetsStore - Refactored (Option A)', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useAssetsStore()
vi.clearAllMocks()
mockAssetMapperOptions.omitCreatedAtForIds.clear()
})
describe('Initial Load', () => {
@@ -282,17 +272,6 @@ describe('assetsStore - Refactored (Option A)', () => {
'prompt_2'
])
})
it('should skip unfinished jobs and completed jobs without previews', async () => {
vi.mocked(api.getHistory).mockResolvedValue([
{ ...createMockJobItem(0), status: 'in_progress' },
{ ...createMockJobItem(1), preview_output: undefined }
])
await store.updateHistory()
expect(store.historyAssets).toEqual([])
})
})
describe('Pagination', () => {
@@ -349,46 +328,6 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(uniqueAssetIds.size).toBe(store.historyAssets.length)
})
it('should insert newer paginated items in sorted order', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(-1)])
await store.loadMoreHistory()
expect(store.historyAssets[0].id).toBe('prompt_-1')
})
it('sorts paginated items when the incoming asset has no timestamp', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
mockAssetMapperOptions.omitCreatedAtForIds.add('prompt_200')
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(200)])
await store.loadMoreHistory()
expect(store.historyAssets.at(-1)?.id).toBe('prompt_200')
})
it('sorts paginated items when an existing asset has no timestamp', async () => {
for (let i = 0; i < 200; i++) {
mockAssetMapperOptions.omitCreatedAtForIds.add(`prompt_${i}`)
}
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(-1)])
await store.loadMoreHistory()
expect(store.historyAssets[0].id).toBe('prompt_-1')
})
it('should stop loading when no more items', async () => {
// First batch - less than BATCH_SIZE
const firstBatch = Array.from({ length: 50 }, (_, i) =>
@@ -555,29 +494,6 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(store.historyLoading).toBe(false)
expect(store.historyError).toBe(error)
})
it('should preserve existing history when refresh fails', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(0)])
await store.updateHistory()
const error = new Error('API error')
vi.mocked(api.getHistory).mockRejectedValueOnce(error)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
expect(store.historyError).toBe(error)
})
it('should keep empty history when loadMore fails before any load', async () => {
const error = new Error('API error')
vi.mocked(api.getHistory).mockRejectedValueOnce(error)
await store.loadMoreHistory()
expect(store.historyAssets).toEqual([])
expect(store.historyError).toBe(error)
})
})
describe('Memory Management', () => {
@@ -1008,43 +924,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
vi.mocked(assetService.getAssetsForNodeType)
).toHaveBeenCalledTimes(2)
})
it('ignores a model response after the category is invalidated', async () => {
const store = useAssetsStore()
let resolveFetch!: (assets: AssetItem[]) => void
vi.mocked(assetService.getAssetsForNodeType).mockReturnValueOnce(
new Promise((resolve) => {
resolveFetch = resolve
})
)
const request = store.updateModelsForNodeType('CheckpointLoaderSimple')
store.invalidateCategory('checkpoints')
resolveFetch([createMockAsset('stale-response')])
await request
expect(store.getAssets('CheckpointLoaderSimple')).toEqual([])
})
it('ignores a model rejection after the category is invalidated', async () => {
const store = useAssetsStore()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
let rejectFetch!: (error: Error) => void
vi.mocked(assetService.getAssetsForNodeType).mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectFetch = reject
})
)
const request = store.updateModelsForNodeType('CheckpointLoaderSimple')
store.invalidateCategory('checkpoints')
rejectFetch(new Error('stale rejection'))
await request
expect(store.getError('CheckpointLoaderSimple')).toBeUndefined()
expect(consoleSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
})
describe('shallowReactive state reactivity', () => {
@@ -1087,10 +966,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
it('should return empty array for unknown node types', () => {
const store = useAssetsStore()
expect(store.getAssets('UnknownNodeType')).toEqual([])
expect(store.isModelLoading('UnknownNodeType')).toBe(false)
expect(store.getError('UnknownNodeType')).toBeUndefined()
expect(store.hasMore('UnknownNodeType')).toBe(false)
expect(store.hasAssetKey('UnknownNodeType')).toBe(false)
})
it('should not fetch for unknown node types', async () => {
@@ -1100,63 +975,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
vi.mocked(assetService.getAssetsForNodeType)
).not.toHaveBeenCalled()
})
it('should refresh an already loaded category', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('first')
])
await store.updateModelsForNodeType(nodeType)
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('second')
])
await store.updateModelsForNodeType(nodeType)
expect(store.getAssets(nodeType).map((asset) => asset.id)).toEqual([
'second'
])
})
it('reports hasMore for a loaded category', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
expect(store.hasMore(nodeType)).toBe(false)
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('only-page')
])
await store.updateModelsForNodeType(nodeType)
expect(store.hasMore(nodeType)).toBe(false)
})
it('should record model loading errors', async () => {
const store = useAssetsStore()
const error = new Error('model fetch failed')
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce(error)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getError('CheckpointLoaderSimple')).toBe(error)
expect(store.isModelLoading('CheckpointLoaderSimple')).toBe(false)
consoleSpy.mockRestore()
})
it('should wrap non-error model loading failures', async () => {
const store = useAssetsStore()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce('boom')
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getError('CheckpointLoaderSimple')?.message).toBe('boom')
consoleSpy.mockRestore()
})
})
describe('invalidateCategory', () => {
@@ -1311,140 +1129,7 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
})
})
describe('completed download refresh', () => {
it('refreshes provider and tag caches for the completed model type', async () => {
const store = useAssetsStore()
const downloadStore = useAssetDownloadStore()
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue([])
vi.mocked(assetService.getAssetsByTag).mockResolvedValue([])
downloadStore.lastCompletedDownload = {
taskId: 'task-1',
modelType: 'checkpoints',
timestamp: 1
}
await vi.waitFor(() =>
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
'models',
true,
expect.objectContaining({ limit: 500, offset: 0 })
)
)
expect(assetService.getAssetsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
expect.objectContaining({ limit: 500, offset: 0 })
)
expect(assetService.getAssetsForNodeType).toHaveBeenCalledTimes(1)
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
'checkpoints',
true,
expect.objectContaining({ limit: 500, offset: 0 })
)
expect(store.hasCategory('tag:models')).toBe(true)
})
})
describe('updateAssetMetadata optimistic cache', () => {
it('still writes metadata when a cache key is unresolved', async () => {
const store = useAssetsStore()
const original = {
...createMockAsset('opt-unknown'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...original,
user_metadata: { note: 'after' }
})
await store.updateAssetMetadata(
original,
{ note: 'after' },
'UnknownNodeType'
)
expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith(
'opt-unknown',
{ user_metadata: { note: 'after' } }
)
})
it('still updates the server when the asset is not cached', async () => {
const store = useAssetsStore()
const original = {
...createMockAsset('opt-missing'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...original,
user_metadata: { note: 'server' }
})
await store.updateAssetMetadata(original, { note: 'after' })
expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith(
'opt-missing',
{ user_metadata: { note: 'after' } }
)
})
it('still updates the server when a resolved cache key has not loaded yet', async () => {
const store = useAssetsStore()
const original = {
...createMockAsset('opt-unloaded'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...original,
user_metadata: { note: 'server' }
})
await store.updateAssetMetadata(
original,
{ note: 'after' },
'CheckpointLoaderSimple'
)
expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith(
'opt-unloaded',
{ user_metadata: { note: 'after' } }
)
})
it('leaves unrelated cached assets alone during optimistic metadata update', async () => {
const store = useAssetsStore()
const cached = {
...createMockAsset('opt-cached'),
user_metadata: { note: 'cached' } as Record<string, unknown>
}
const missing = {
...createMockAsset('opt-missing-from-cache'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
cached
])
await store.updateModelsForNodeType('CheckpointLoaderSimple')
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...missing,
user_metadata: { note: 'server' }
})
await store.updateAssetMetadata(
missing,
{ note: 'after' },
'CheckpointLoaderSimple'
)
expect(
store.getAssets('CheckpointLoaderSimple')[0].user_metadata
).toEqual({
note: 'cached'
})
})
it('reflects the server response in the cache after a successful update', async () => {
const store = useAssetsStore()
const original = {
@@ -1552,31 +1237,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
'featured'
])
})
it('calls only the remove endpoint when there are no tags to add', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-remove-only', ['models', 'archived'])
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
asset
])
await store.updateModelsForNodeType('CheckpointLoaderSimple')
vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({
total_tags: ['models']
})
await store.updateAssetTags(asset, ['models'], 'CheckpointLoaderSimple')
expect(vi.mocked(assetService.removeAssetTags)).toHaveBeenCalledWith(
'tags-remove-only',
['archived']
)
expect(vi.mocked(assetService.addAssetTags)).not.toHaveBeenCalled()
expect(store.getAssets('CheckpointLoaderSimple')[0].tags).toEqual([
'models'
])
})
})
describe('updateAssetTags partial-failure compensation', () => {
@@ -1691,36 +1351,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
expect(store.hasCategory('tag:models')).toBe(false)
})
it('keeps unrelated tag caches when compensation fails with a cache key', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-target-fail', ['models', 'loras'])
const otherAsset = createMockAsset('tags-other', ['models'])
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
asset
])
await store.updateModelsForNodeType('LoraLoader')
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([otherAsset])
await store.updateModelsForTag('models')
vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({
removed: ['loras'],
total_tags: ['models']
})
vi.mocked(assetService.addAssetTags)
.mockRejectedValueOnce(new Error('500 add failed'))
.mockRejectedValueOnce(new Error('503 compensation failed'))
await store.updateAssetTags(
asset,
['models', 'checkpoints'],
'LoraLoader'
)
expect(store.hasCategory('loras')).toBe(false)
expect(store.hasCategory('tag:models')).toBe(true)
})
it('does not attempt compensation when only the add was attempted', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-add-only-fail', ['models'])
@@ -1853,78 +1483,9 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
const store = useAssetsStore()
expect(store.getInputName('unknown.png')).toBe('unknown.png')
})
it('ignores input assets without hashes', async () => {
mockIsCloud.value = true
try {
setActivePinia(createTestingPinia({ stubActions: false }))
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
{
id: 'input-1',
name: 'plain.png',
tags: ['input']
}
])
await store.updateInputs()
expect(store.getInputName('plain.png')).toBe('plain.png')
} finally {
mockIsCloud.value = false
}
})
})
describe('updateInputs cloud routing', () => {
it('reads input files from the internal API when isCloud is false', async () => {
const fetchMock = vi.fn().mockResolvedValue(
fromAny<Response, unknown>({
ok: true,
json: async () => ['input-a.png', 'input-b.png']
})
)
vi.stubGlobal('fetch', fetchMock)
try {
const store = useAssetsStore()
await store.updateInputs()
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3000/files/input',
{ headers: { 'Comfy-User': 'test-user' } }
)
expect(store.inputAssets.map((asset) => asset.name)).toEqual([
'input-a.png',
'input-b.png'
])
} finally {
vi.unstubAllGlobals()
}
})
it('records internal input API failures', async () => {
const fetchMock = vi.fn().mockResolvedValue(
fromAny<Response, unknown>({
ok: false
})
)
vi.stubGlobal('fetch', fetchMock)
try {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const store = useAssetsStore()
await store.updateInputs()
expect(store.inputError).toBeInstanceOf(Error)
consoleSpy.mockRestore()
} finally {
vi.unstubAllGlobals()
}
})
it('reads from assetService.getAssetsByTag with limit 100 when isCloud is true', async () => {
mockIsCloud.value = true
try {
@@ -2025,18 +1586,6 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputHasMore).toBe(false)
})
it('does not load more flat outputs when there are no more pages', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([makeAsset('a1', 'one.png')])
)
const store = useAssetsStore()
await store.updateFlatOutputs()
await store.loadMoreFlatOutputs()
expect(assetService.getAssetsPageByTag).toHaveBeenCalledTimes(1)
})
it('threads the minted cursor into after on loadMore and omits offset', async () => {
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(
@@ -2251,26 +1800,4 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
})
it('ignores concurrent load more calls while one is active', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png')], { hasMore: true })
)
const store = useAssetsStore()
await store.updateFlatOutputs()
let resolvePage!: (page: AssetResponse) => void
vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(
new Promise<AssetResponse>((resolve) => {
resolvePage = resolve
})
)
const first = store.loadMoreFlatOutputs()
const second = store.loadMoreFlatOutputs()
resolvePage(makePage([makeAsset('a2', 'f2.png')]))
await Promise.all([first, second])
expect(assetService.getAssetsPageByTag).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
@@ -178,10 +177,9 @@ describe('useComfyRegistryStore', () => {
it('should return null when fetching a pack with null ID', async () => {
const store = useComfyRegistryStore()
vi.spyOn(store.getPackById, 'call').mockResolvedValueOnce(null)
const result = await store.getPackById.call(
fromAny<Parameters<typeof store.getPackById.call>[0], unknown>(null)
)
const result = await store.getPackById.call(null!)
expect(result).toBeNull()
expect(mockRegistryService.getPackById).not.toHaveBeenCalled()
@@ -208,56 +206,6 @@ describe('useComfyRegistryStore', () => {
)
})
it('should reuse cached packs by ID', async () => {
const store = useComfyRegistryStore()
await store.getPacksByIds.call(['test-pack-id'])
const result = await store.getPacksByIds.call(['test-pack-id'])
expect(result).toEqual([mockNodePack])
expect(mockRegistryService.listAllPacks).toHaveBeenCalledTimes(1)
})
it('should ignore missing packs by ID', async () => {
mockRegistryService.listAllPacks.mockResolvedValueOnce({
nodes: [fromAny<components['schemas']['Node'], unknown>({ name: 'bad' })],
total: 1,
page: 1,
limit: 10
})
const store = useComfyRegistryStore()
const result = await store.getPacksByIds.call(['unknown-pack-id'])
expect(result).toEqual([])
})
it('should handle empty pack lookup responses', async () => {
mockRegistryService.listAllPacks.mockResolvedValueOnce(null)
const store = useComfyRegistryStore()
const result = await store.getPacksByIds.call(['unknown-pack-id'])
expect(result).toEqual([])
})
it('should filter undefined pack IDs before lookup', async () => {
const store = useComfyRegistryStore()
const result = await store.getPacksByIds.call(
fromAny<components['schemas']['Node']['id'][], unknown>([
'test-pack-id',
undefined
])
)
expect(result).toEqual([mockNodePack])
expect(mockRegistryService.listAllPacks).toHaveBeenCalledWith(
{ node_id: ['test-pack-id'] },
expect.any(Object)
)
})
describe('inferPackFromNodeName', () => {
it('should fetch a pack by comfy node name', async () => {
const store = useComfyRegistryStore()

View File

@@ -137,88 +137,6 @@ describe('useModelStore', () => {
expect(model.resolution).toBe('')
})
it('keeps the default model metadata when the server returns null', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce(null)
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('sdxl')
expect(model.has_loaded_metadata).toBe(false)
})
it('loads model metadata once', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
await model.load()
expect(api.viewMetadata).toHaveBeenCalledTimes(1)
})
it('keeps the default title when the first metadata key is empty', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce({
'modelspec.title': '',
display_name: 'Fallback title'
})
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('sdxl')
})
it('returns null for unknown loaded model folders', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
await expect(store.getLoadedModelFolder('missing')).resolves.toBeNull()
})
it('should read metadata from suffixed keys and ignore null values', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce({
'custom.modelspec.title': 'Namespaced title',
'custom.modelspec.author': null,
'custom.modelspec.tags': null
})
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('Namespaced title')
expect(model.author).toBe('')
expect(model.tags).toEqual([''])
})
it('should keep extensions for non-safetensors files', async () => {
enableMocks()
vi.mocked(api.getModels).mockResolvedValueOnce([
{ name: 'notes.txt', pathIndex: 0 }
])
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore!.models['0/notes.txt'].title).toBe('notes.txt')
})
it('should cache model information', async () => {
enableMocks()
store = useModelStore()
@@ -291,23 +209,6 @@ describe('useModelStore', () => {
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
expect(api.getModels).not.toHaveBeenCalled()
})
it('does not reload previously loaded folders that disappear', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
await store.getLoadedModelFolder('checkpoints')
vi.mocked(api.getModelFolders).mockResolvedValueOnce([
{ name: 'vae', folders: ['/path/to/vae'] }
])
await store.refresh()
expect(store.modelFolders.map((folder) => folder.directory)).toEqual([
'vae'
])
expect(api.getModels).toHaveBeenCalledTimes(1)
})
})
describe('API switching functionality', () => {

View File

@@ -138,22 +138,6 @@ describe('useModelToNodeStore', () => {
expect(provider?.key).toBe('ckpt_name')
})
it('omits providers whose node definition is unavailable from reverse lookup', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.modelToNodeMap = {
missing: [
new ModelNodeProvider(
undefined as unknown as ComfyNodeDefImpl,
'model'
)
]
}
expect(modelToNodeStore.getRegisteredNodeTypes()).not.toHaveProperty(
'undefined'
)
})
it('should return undefined for unregistered model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
@@ -593,22 +577,6 @@ describe('useModelToNodeStore', () => {
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
})
it('skips providers without node definitions during category lookup', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.modelToNodeMap = {
missing: [
new ModelNodeProvider(
undefined as unknown as ComfyNodeDefImpl,
'model'
)
]
}
expect(
modelToNodeStore.getCategoryForNodeType('MissingNode')
).toBeUndefined()
})
it('maps the IC-LoRA Loader Model Only node to loras so its lora_name dropdown uses the cloud asset browser (FE-838)', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()

View File

@@ -1,24 +1,16 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import axios from 'axios'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import {
ComfyNodeDefImpl,
buildNodeDefTree,
createDummyFolderNodeDef,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { NodeDefFilter } from '@/stores/nodeDefStore'
describe('useNodeDefStore', () => {
@@ -29,10 +21,6 @@ describe('useNodeDefStore', () => {
store = useNodeDefStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef => ({
@@ -51,112 +39,7 @@ describe('useNodeDefStore', () => {
...overrides
})
describe('ComfyNodeDefImpl', () => {
it('migrates defaultInput options and applies constructor fallbacks', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const nodeDef = createMockNodeDef({
category: '_for_testing/coverage',
deprecated: undefined,
dev_only: undefined,
experimental: undefined,
help: undefined,
input: {
required: { prompt: ['STRING', { defaultInput: true }] },
optional: { seed_override: ['INT', { defaultInput: true }] }
}
})
const impl = new ComfyNodeDefImpl(nodeDef)
expect(warn).toHaveBeenCalledTimes(2)
expect(impl.help).toBe('')
expect(impl.experimental).toBe(true)
expect(impl.dev_only).toBe(false)
expect(impl.inputs.seed_override.forceInput).toBe(true)
})
it('derives empty-category node paths and lifecycle badges', () => {
const deprecated = new ComfyNodeDefImpl(
createMockNodeDef({ category: '', deprecated: undefined })
)
const beta = new ComfyNodeDefImpl(
createMockNodeDef({ experimental: true })
)
const dev = new ComfyNodeDefImpl(createMockNodeDef({ dev_only: true }))
const normal = new ComfyNodeDefImpl(createMockNodeDef())
expect(deprecated.nodePath).toBe('TestNode')
expect(deprecated.isDummyFolder).toBe(false)
expect(deprecated.nodeLifeCycleBadgeText).toBe('[DEPR]')
expect(beta.nodeLifeCycleBadgeText).toBe('[BETA]')
expect(dev.nodeLifeCycleBadgeText).toBe('[DEV]')
expect(normal.nodeLifeCycleBadgeText).toBe('')
})
it('defaults missing legacy input and output fields', () => {
const nodeDef = new ComfyNodeDefImpl(
fromAny<ComfyNodeDef, unknown>({
name: 'FallbackNode',
display_name: 'Fallback Node',
category: 'test',
python_module: 'test_module',
description: 'Test node',
output_node: false
})
)
expect(nodeDef.input).toEqual({})
expect(nodeDef.output).toEqual([])
})
it('post-processes search scores with node frequency', async () => {
vi.spyOn(axios, 'get').mockResolvedValue({ data: { TestNode: 7 } })
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
const nodeDef = new ComfyNodeDefImpl(createMockNodeDef())
expect(nodeDef.postProcessSearchScores([10, 4, 2])).toEqual([
10, -7, 4, 2
])
})
})
describe('tree helpers', () => {
it('builds node definition trees from default and custom paths', () => {
const nodeDef = new ComfyNodeDefImpl(
createMockNodeDef({ name: 'TreeNode', category: 'root/branch' })
)
expect(buildNodeDefTree([nodeDef]).children?.[0].label).toBe('root')
expect(
buildNodeDefTree([nodeDef], {
pathExtractor: (node) => ['custom', node.name]
}).children?.[0].label
).toBe('custom')
})
it('normalizes dummy folder paths', () => {
expect(createDummyFolderNodeDef('folder/').category).toBe('folder')
expect(createDummyFolderNodeDef('folder').category).toBe('folder')
})
})
describe('filter registry', () => {
it('updates LiteGraph skip state for registered dev-only nodes', () => {
const registeredNodeTypes = LiteGraph.registered_node_types
LiteGraph.registered_node_types = fromAny({
DevNode: { nodeData: { dev_only: true }, skip_list: false },
NormalNode: { nodeData: {}, skip_list: false }
})
setActivePinia(createTestingPinia({ stubActions: false }))
useNodeDefStore()
expect(LiteGraph.registered_node_types.DevNode.skip_list).toBe(true)
expect(LiteGraph.registered_node_types.NormalNode.skip_list).toBe(false)
LiteGraph.registered_node_types = registeredNodeTypes
})
it('should register a new filter', () => {
const filter: NodeDefFilter = {
id: 'test.filter',
@@ -404,26 +287,6 @@ describe('useNodeDefStore', () => {
})
describe('allNodeDefsByName', () => {
it('keeps existing ComfyNodeDefImpl instances during updates', () => {
const nodeDef = new ComfyNodeDefImpl(
createMockNodeDef({ name: 'ExistingImpl' })
)
store.updateNodeDefs([nodeDef])
expect(store.nodeDefsByName.ExistingImpl.name).toBe('ExistingImpl')
expect(store.nodeDefsByDisplayName['Test Node'].name).toBe('ExistingImpl')
})
it('adds one node definition to the name and display-name indexes', () => {
store.addNodeDef(
createMockNodeDef({ name: 'AddedNode', display_name: 'Added Node' })
)
expect(store.nodeDefsByName.AddedNode.name).toBe('AddedNode')
expect(store.nodeDefsByDisplayName['Added Node'].name).toBe('AddedNode')
})
it('should include all node defs by name', () => {
const node1 = createMockNodeDef({ name: 'Node1' })
const node2 = createMockNodeDef({ name: 'Node2' })
@@ -473,39 +336,6 @@ describe('useNodeDefStore', () => {
expect(store.allNodeDefsByName).toHaveProperty('Normal')
expect(store.allNodeDefsByName).toHaveProperty('Deprecated')
})
it('derives unique input and output data types', () => {
store.updateNodeDefs([
createMockNodeDef({
input: {
required: { image: ['IMAGE', {}] },
optional: { mask: ['MASK', {}] }
},
output: ['IMAGE', 'LATENT'],
output_is_list: [false, false],
output_name: ['image', 'latent']
})
])
expect([...store.nodeDataTypes].sort()).toEqual([
'IMAGE',
'LATENT',
'MASK'
])
})
it('looks up node definitions from graph nodes and returns null for misses', () => {
store.updateNodeDefs([createMockNodeDef({ name: 'KnownNode' })])
expect(
store.fromLGraphNode(new LGraphNode('KnownNode', 'KnownNode'))?.name
).toBe('KnownNode')
expect(store.fromLGraphNode(new LGraphNode('', ''))).toBeNull()
expect(
store.getInputSpecForWidget(new LGraphNode('Missing', 'Missing'), 'x')
).toBeUndefined()
expect(store.nodeSearchService).toBeDefined()
})
})
describe('subgraph widget input specs', () => {
@@ -559,94 +389,6 @@ describe('useNodeDefStore', () => {
expect(spec?.type).toBe('STRING')
expect(spec?.default).toBeUndefined()
})
it('returns undefined for missing promoted subgraph inputs', () => {
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
expect(store.getInputSpecForWidget(host, 'missing')).toBeUndefined()
})
it('returns undefined when a subgraph input is not promoted', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
host.addInput('raw', 'STRING')
expect(store.getInputSpecForWidget(host, 'raw')).toBeUndefined()
})
it('returns undefined when a promoted source no longer resolves', () => {
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
host.subgraph.nodes[0].widgets = []
expect(store.getInputSpecForWidget(host, 'prompt')).toBeUndefined()
})
it('returns undefined when concrete promoted widget resolution fails', async () => {
const resolver =
await import('@/core/graph/subgraph/resolveConcretePromotedWidget')
vi.spyOn(resolver, 'resolveConcretePromotedWidget').mockReturnValue(
fromAny({ status: 'failure', failure: 'missing-widget' })
)
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
expect(store.getInputSpecForWidget(host, 'prompt')).toBeUndefined()
})
})
describe('node frequency store', () => {
it('loads frequencies once and exposes top matching node definitions', async () => {
const get = vi.spyOn(axios, 'get').mockResolvedValue({
data: { RankedNode: 10, MissingNode: 3 }
})
store.updateNodeDefs([createMockNodeDef({ name: 'RankedNode' })])
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
await frequencyStore.loadNodeFrequencies()
expect(get).toHaveBeenCalledTimes(1)
expect(frequencyStore.isLoaded).toBe(true)
expect(frequencyStore.getNodeFrequencyByName('RankedNode')).toBe(10)
expect(
frequencyStore.getNodeFrequency(
new ComfyNodeDefImpl(createMockNodeDef({ name: 'RankedNode' }))
)
).toBe(10)
expect(frequencyStore.getNodeFrequencyByName('Unknown')).toBe(0)
expect(frequencyStore.topNodeDefs.map((nodeDef) => nodeDef.name)).toEqual(
['RankedNode']
)
})
it('leaves frequency state unloaded when loading fails', async () => {
const error = new Error('boom')
vi.spyOn(axios, 'get').mockRejectedValue(error)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
expect(frequencyStore.isLoaded).toBe(false)
expect(errorSpy).toHaveBeenCalledWith(
'Error loading node frequencies:',
error
)
})
})
describe('performance', () => {

View File

@@ -3,41 +3,15 @@ import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import * as litegraphUtil from '@/utils/litegraphUtil'
const {
mockApiURL,
mockExecutionIdToNodeLocatorId,
mockNodeIdToNodeLocatorId,
mockNodeToNodeLocatorId,
mockReleaseSharedObjectUrl,
mockRetainSharedObjectUrl
} = vi.hoisted(() => ({
mockApiURL: vi.fn((path: string) => `api${path}`),
mockExecutionIdToNodeLocatorId: vi.fn(
(_rootGraph: unknown, id: NodeExecutionId) => id as unknown as NodeLocatorId
),
mockNodeIdToNodeLocatorId: vi.fn(
(id: string | number) => String(id) as NodeLocatorId
),
mockNodeToNodeLocatorId: vi.fn(
(node: { id: string | number }) => String(node.id) as NodeLocatorId
),
mockReleaseSharedObjectUrl: vi.fn(),
mockRetainSharedObjectUrl: vi.fn()
}))
const mockResolveNode = vi.fn()
vi.mock('@/utils/litegraphUtil', () => ({
@@ -46,25 +20,11 @@ vi.mock('@/utils/litegraphUtil', () => ({
resolveNode: (...args: unknown[]) => mockResolveNode(...args)
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (...args: Parameters<typeof mockApiURL>) => mockApiURL(...args)
}
}))
vi.mock('@/utils/objectUrlUtil', () => ({
releaseSharedObjectUrl: (...args: [string | undefined]) =>
mockReleaseSharedObjectUrl(...args),
retainSharedObjectUrl: (...args: [string | undefined]) =>
mockRetainSharedObjectUrl(...args)
}))
const mockGetNodeById = vi.fn()
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
getRandParam: vi.fn(() => '&rand=1'),
rootGraph: {
getNodeById: (...args: unknown[]) => mockGetNodeById(...args)
},
@@ -89,31 +49,13 @@ const createMockOutputs = (
): ExecutedWsMessage['output'] => ({ images })
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: (
...args: Parameters<typeof mockExecutionIdToNodeLocatorId>
) => mockExecutionIdToNodeLocatorId(...args)
executionIdToNodeLocatorId: vi.fn((_rootGraph: unknown, id: string) => id)
}))
beforeEach(() => {
mockExecutionIdToNodeLocatorId.mockImplementation(
(_rootGraph: unknown, id: NodeExecutionId) => id as unknown as NodeLocatorId
)
mockNodeIdToNodeLocatorId.mockImplementation(
(id: string | number) => String(id) as NodeLocatorId
)
mockNodeToNodeLocatorId.mockImplementation(
(node: { id: string | number }) => String(node.id) as NodeLocatorId
)
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
nodeIdToNodeLocatorId: (
...args: Parameters<typeof mockNodeIdToNodeLocatorId>
) => mockNodeIdToNodeLocatorId(...args),
nodeToNodeLocatorId: (
...args: Parameters<typeof mockNodeToNodeLocatorId>
) => mockNodeToNodeLocatorId(...args)
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id))
}))
}))
@@ -838,19 +780,6 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => {
expect(store.nodeOutputs['5']?.images?.[0]?.type).toBe('input')
})
it('ignores widget outputs when no locator can be resolved', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
mockNodeToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.setNodeOutputs(node, 'test.png')
expect(store.nodeOutputs).toEqual({})
expect(app.nodeOutputs).toEqual({})
})
it('should skip empty array of filenames after createOutputs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
@@ -860,470 +789,6 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => {
expect(store.nodeOutputs['5']).toBeUndefined()
expect(app.nodeOutputs['5']).toBeUndefined()
})
it('stores direct result items without wrapping them as image outputs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, { filename: 'direct.png', type: 'temp' })
expect(store.nodeOutputs['5']).toEqual({
filename: 'direct.png',
type: 'temp'
})
})
it('marks animated webp and png filenames when requested', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, ['clip.webp', 'still.jpg', 'mask.png'], {
folder: 'output',
isAnimated: true
})
expect(store.nodeOutputs['5']?.animated).toEqual([true, false, true])
expect(store.nodeOutputs['5']?.images?.map((image) => image.type)).toEqual([
'output',
'output',
'output'
])
})
})
describe('nodeOutputStore image URLs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(false)
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false)
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('returns stored preview URLs before output URLs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodePreviewsByLocatorId(createNodeLocatorId(null, toNodeId(5)), [
'blob:preview'
])
expect(store.getNodeImageUrls(node)).toEqual(['blob:preview'])
expect(mockApiURL).not.toHaveBeenCalled()
})
it('builds view URLs from output images', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
app.nodeOutputs['5'] = createMockOutputs(
fromAny([{ filename: 'a.png', subfolder: 'x', type: 'temp' }, null])
)
expect(store.getNodeImageUrls(node)).toEqual([
'api/view?filename=a.png&subfolder=x&type=temp&format=test_webp&rand=1'
])
})
it('returns undefined when a node has neither previews nor outputs', () => {
const store = useNodeOutputStore()
expect(store.getNodeImageUrls(createMockNode({ id: 5 }))).toBeUndefined()
})
it('returns execution previews before execution output URLs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodePreviewsByExecutionId(executionId, ['blob:preview'])
expect(store.getNodeImageUrlsByExecutionId(executionId, node)).toEqual([
'blob:preview'
])
expect(store.latestPreview).toEqual(['blob:preview'])
expect(mockApiURL).not.toHaveBeenCalled()
})
it('falls back to execution output URLs when no preview exists', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png', type: 'temp' }])
)
expect(store.getNodeImageUrlsByExecutionId(executionId, node)).toEqual([
'api/view?filename=result.png&type=temp&format=test_webp&rand=1'
])
})
})
describe('nodeOutputStore locator misses', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('keeps execution operations inert when no locator can be resolved', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
mockExecutionIdToNodeLocatorId.mockReturnValue(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png' }])
)
store.setNodePreviewsByExecutionId(executionId, ['blob:preview'])
store.revokePreviewsByExecutionId(executionId)
expect(store.getNodeOutputByExecutionId(executionId)).toBeUndefined()
expect(store.getNodePreviewImagesByExecutionId(executionId)).toBeUndefined()
expect(store.nodeOutputs).toEqual({})
expect(store.nodePreviewImages).toEqual({})
})
})
describe('nodeOutputStore merge branches', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('sets outputs when merge is requested without existing output', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
const output = createMockOutputs([{ filename: 'first.png' }])
store.setNodeOutputsByExecutionId(executionId, output, { merge: true })
expect(store.nodeOutputs[executionId]).toEqual(output)
})
it('ignores null outputs', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
fromAny<ExecutedWsMessage['output'], unknown>(null)
)
expect(store.nodeOutputs[executionId]).toBeUndefined()
})
it('overwrites non-array fields during merge', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
const firstOutput: ExecutedWsMessage['output'] = {
images: [{ filename: 'first.png' }],
text: 'old'
}
store.setNodeOutputsByExecutionId(executionId, firstOutput)
store.setNodeOutputsByExecutionId(
executionId,
{ text: ['new'] },
{ merge: true }
)
expect(store.nodeOutputs[executionId]?.images).toEqual([
{ filename: 'first.png' }
])
expect(store.nodeOutputs[executionId]?.text).toEqual(['new'])
})
})
describe('nodeOutputStore previews and removal', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('releases old previews and retains new previews on replacement', () => {
const store = useNodeOutputStore()
const locatorId = createNodeLocatorId(null, toNodeId(5))
store.setNodePreviewsByLocatorId(locatorId, ['blob:first'])
store.setNodePreviewsByLocatorId(locatorId, ['blob:second'])
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:first')
expect(mockRetainSharedObjectUrl).toHaveBeenCalledWith('blob:second')
expect(store.nodePreviewImages[locatorId]).toEqual(['blob:second'])
})
it('starts with an empty preview map when legacy previews are missing', () => {
app.nodePreviewImages = fromAny(undefined)
const store = useNodeOutputStore()
expect(store.nodePreviewImages).toEqual({})
})
it('cancels scheduled revocation when a newer preview arrives', async () => {
vi.useFakeTimers()
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodePreviewsByExecutionId(executionId, ['blob:first'])
store.revokePreviewsByExecutionId(executionId)
store.setNodePreviewsByExecutionId(executionId, ['blob:second'])
await vi.advanceTimersByTimeAsync(400)
vi.useRealTimers()
expect(store.nodePreviewImages[executionId]).toEqual(['blob:second'])
expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalledWith('blob:second')
})
it('revokes locator previews and clears preview state', () => {
const store = useNodeOutputStore()
const locatorId = createNodeLocatorId(null, toNodeId(5))
store.setNodePreviewsByLocatorId(locatorId, ['blob:first'])
store.revokePreviewsByLocatorId(locatorId)
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:first')
expect(store.nodePreviewImages[locatorId]).toBeUndefined()
expect(app.nodePreviewImages[locatorId]).toBeUndefined()
})
it('leaves state unchanged when revoking a locator with no previews', () => {
const store = useNodeOutputStore()
store.revokePreviewsByLocatorId(createNodeLocatorId(null, toNodeId(5)))
expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalled()
expect(store.nodePreviewImages).toEqual({})
})
it('skips non-iterable preview entries when revoking all previews', () => {
const store = useNodeOutputStore()
app.nodePreviewImages = fromAny({
'5': {},
'6': ['blob:preview']
})
store.revokeAllPreviews()
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledTimes(1)
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:preview')
expect(store.nodePreviewImages).toEqual({})
})
it('revokes subgraph previews for the parent node and child nodes', () => {
const store = useNodeOutputStore()
const subgraphId = '11111111-1111-1111-1111-111111111111'
const parentLocatorId = createNodeLocatorId(null, toNodeId(9))
const childLocatorId = createNodeLocatorId(subgraphId, toNodeId(10))
const subgraphNode = fromAny<SubgraphNode, unknown>({
id: toNodeId(9),
graph: { isRootGraph: true },
subgraph: {
id: subgraphId,
nodes: [createMockNode({ id: 10 })]
}
})
store.setNodePreviewsByLocatorId(parentLocatorId, ['blob:parent'])
store.setNodePreviewsByLocatorId(childLocatorId, ['blob:child'])
store.revokeSubgraphPreviews(subgraphNode)
expect(store.nodePreviewImages[parentLocatorId]).toBeUndefined()
expect(store.nodePreviewImages[childLocatorId]).toBeUndefined()
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:parent')
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:child')
})
it('uses the parent graph id for non-root subgraph preview revocation', () => {
const store = useNodeOutputStore()
const graphId = '22222222-2222-2222-2222-222222222222'
const subgraphId = '33333333-3333-3333-3333-333333333333'
const parentLocatorId = createNodeLocatorId(graphId, toNodeId(9))
const subgraphNode = fromAny<SubgraphNode, unknown>({
id: toNodeId(9),
graph: { id: graphId, isRootGraph: false },
subgraph: { id: subgraphId, nodes: [] }
})
store.setNodePreviewsByLocatorId(parentLocatorId, ['blob:parent'])
store.revokeSubgraphPreviews(subgraphNode)
expect(store.nodePreviewImages[parentLocatorId]).toBeUndefined()
})
it('leaves previews alone when a subgraph node has no parent graph', () => {
const store = useNodeOutputStore()
const locatorId = createNodeLocatorId(null, toNodeId(9))
const subgraphNode = fromAny<SubgraphNode, unknown>({
graph: undefined,
subgraph: { nodes: [] }
})
store.setNodePreviewsByLocatorId(locatorId, ['blob:parent'])
store.revokeSubgraphPreviews(subgraphNode)
expect(store.nodePreviewImages[locatorId]).toEqual(['blob:parent'])
})
it('removes outputs and previews for a node id', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png' }])
)
store.setNodePreviewsByExecutionId(executionId, ['blob:preview'])
expect(store.removeNodeOutputs(toNodeId(5))).toBe(true)
expect(store.nodeOutputs[executionId]).toBeUndefined()
expect(store.nodePreviewImages[executionId]).toBeUndefined()
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:preview')
})
it('returns false when removing outputs for a node with no outputs', () => {
const store = useNodeOutputStore()
expect(store.removeNodeOutputsForNode(createMockNode({ id: 9 }))).toBe(
false
)
})
it('returns false when a node id cannot resolve to a locator', () => {
const store = useNodeOutputStore()
mockNodeIdToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
expect(store.removeNodeOutputs(toNodeId(9))).toBe(false)
})
it('removes preview state even when preview entries are not iterable', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png' }])
)
app.nodePreviewImages[executionId] = fromAny({})
store.nodePreviewImages[executionId] = fromAny({})
expect(store.removeNodeOutputs(toNodeId(5))).toBe(true)
expect(store.nodePreviewImages[executionId]).toBeUndefined()
expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalled()
})
})
describe('nodeOutputStore output refresh', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('updates stored output images from legacy node images', () => {
const store = useNodeOutputStore()
const node = createMockNode({
id: 5,
images: [{ filename: 'new.png', type: 'temp' }]
})
store.setNodeOutputsByExecutionId(
createNodeExecutionId([toNodeId(5)]),
createMockOutputs([{ filename: 'old.png', type: 'temp' }])
)
store.updateNodeImages(node)
expect(store.nodeOutputs['5']?.images).toEqual([
{ filename: 'new.png', type: 'temp' }
])
})
it('ignores legacy image updates when the node has no images', () => {
const store = useNodeOutputStore()
store.updateNodeImages(createMockNode({ id: 5 }))
expect(store.nodeOutputs).toEqual({})
})
it('ignores legacy image updates when no locator exists', () => {
const store = useNodeOutputStore()
mockNodeIdToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.updateNodeImages(
createMockNode({ id: 5, images: [{ filename: 'new.png' }] })
)
expect(store.nodeOutputs).toEqual({})
})
it('ignores legacy image updates when no output exists', () => {
const store = useNodeOutputStore()
store.updateNodeImages(
createMockNode({ id: 5, images: [{ filename: 'new.png' }] })
)
expect(store.nodeOutputs).toEqual({})
})
it('copies app outputs into reactive state during refresh', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const output = createMockOutputs([{ filename: 'result.png' }])
app.nodeOutputs['5'] = output
store.refreshNodeOutputs(node)
expect(store.nodeOutputs['5']).toEqual(output)
expect(store.nodeOutputs['5']).not.toBe(output)
})
it('does not refresh when a node has no locator', () => {
const store = useNodeOutputStore()
mockNodeToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.refreshNodeOutputs(createMockNode({ id: 5 }))
expect(store.nodeOutputs).toEqual({})
})
it('does not refresh when app has no output for the node', () => {
const store = useNodeOutputStore()
store.refreshNodeOutputs(createMockNode({ id: 5 }))
expect(store.nodeOutputs).toEqual({})
})
it('keeps unresolved restore output ids as their original ids', () => {
const store = useNodeOutputStore()
const output = createMockOutputs([{ filename: 'saved.png' }])
mockExecutionIdToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.restoreOutputs({ missing: output })
expect(store.nodeOutputs.missing).toEqual(output)
})
})
describe('nodeOutputStore syncLegacyNodeImgs', () => {
@@ -1429,20 +894,4 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
expect(mockNode.imgs).toEqual([mockImg])
expect(mockNode.imageIndex).toBe(0)
})
it('copies output images onto the legacy node', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockResolveNode.mockReturnValue(mockNode)
store.setNodeOutputsByExecutionId(
createNodeExecutionId([toNodeId(1)]),
createMockOutputs([{ filename: 'result.png', type: 'temp' }])
)
store.syncLegacyNodeImgs(toNodeId(1), mockImg)
expect(mockNode.images).toEqual([{ filename: 'result.png', type: 'temp' }])
})
})

View File

@@ -95,22 +95,6 @@ describe(usePreviewExposureStore, () => {
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
})
it('clears only the requested host when other hosts remain', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
store.addExposure(rootGraphA, hostB, {
sourceNodeId: '43',
sourcePreviewName: 'preview'
})
store.setExposures(rootGraphA, hostA, [])
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
expect(store.getExposures(rootGraphA, hostB)).toHaveLength(1)
})
})
describe('removeExposure', () => {
@@ -138,12 +122,6 @@ describe(usePreviewExposureStore, () => {
store.removeExposure(rootGraphA, hostA, 'does-not-exist')
expect(store.getExposures(rootGraphA, hostA)).toEqual(before)
})
it('is a no-op for an unknown host', () => {
store.removeExposure(rootGraphA, 'missing-host', 'preview')
expect(store.getExposures(rootGraphA, 'missing-host')).toEqual([])
})
})
describe('getExposuresAsPromotionShape', () => {