Compare commits

...

4 Commits

Author SHA1 Message Date
Jedrzej Kosinski
c7ccafc75b fix: surface failed manager install reasons in the progress toast
When an install is rejected (e.g. ComfyUI-Manager blocks it due to the
security_level/--listen restriction), the reason is captured in task
history but the progress toast only rendered the streamed server logs --
which stay empty for a request rejected before the task runs. The failed
task also misleadingly showed 'Completed'.

- Expose isTaskFailed and getTaskErrorMessages from the manager store
- Render a task's error messages in its failed panel
- Label failed tasks 'Failed' (in danger color) instead of 'Completed'

Amp-Thread-ID: https://ampcode.com/threads/T-019eafaf-ac38-734b-8fa1-1422ed378e78
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 22:30:45 -07:00
Jedrzej Kosinski
d349677767 fix: prevent infinite manager loading offline and surface backend security messages
- Wrap registry search in try/catch/finally so a failed/offline search clears the loading spinner instead of hanging forever, and expose error + retry
- Add request timeouts to the registry and manager axios clients so hung sockets reject
- Surface ComfyUI-Manager's actionable backend error message (e.g. required security_level/--listen) instead of a generic 403 fallback
- Show a retryable connection-error empty state in the manager dialog

Amp-Thread-ID: https://ampcode.com/threads/T-019eafaf-ac38-734b-8fa1-1422ed378e78
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 22:11:28 -07:00
Benjamin Lu
c190784307 Add share id attribution across share and run telemetry (#12741)
## Summary
- Thread `share_id` through shared workflow open/import, link creation,
auth completion, and run success telemetry
- Persist share attribution on loaded workflows and queued jobs so
shared runs can be joined back to the source link
- Add provider support for `share_link_opened` and `shared_workflow_run`
events across telemetry backends

## Behavior notes
- `execution_success` is now keyed off the success event's own
`prompt_id` (looked up in `queuedJobs`) instead of `activeJobId`. This
fixes successes for non-active jobs being reported with the wrong job
id, but may slightly shift `execution_success` event volume: successes
for jobs this client never queued or saw start are no longer tracked.
- Share auth attribution (`share_auth` preserved query) is cleared if
the user cancels the shared workflow dialog, so only users who proceed
past the dialog have signups attributed to the share link.

## Testing
- Added and updated unit tests for shared workflow loading, link
creation, auth attribution, workflow service loading, and execution
success
- Unit tests, `pnpm test:unit`, and repository checks for formatting,
linting, and type coverage passed
2026-06-09 20:56:31 -07:00
Simon Pinfold
38458c518e feat(assets): include previews in bulk asset export (#12746)
## Summary

Set `include_previews: true` on bulk asset export so exported zips
include preview files, using the new option added to the cloud export
API.

## Changes

- **What**: Added `include_previews` to `AssetExportOptions` and pass
`true` from the bulk export path in `useMediaAssetActions`.

## Review Focus

Field name matches the updated cloud `AssetExportOptions` contract.
2026-06-10 03:03:00 +00:00
36 changed files with 812 additions and 77 deletions

View File

@@ -453,6 +453,7 @@
"totalNodes": "Total Nodes",
"discoverCommunityContent": "Discover community-made Node Packs, Extensions, and more...",
"errorConnecting": "Error connecting to the Comfy Node Registry.",
"retry": "Try Again",
"noResultsFound": "No results found matching your search.",
"tryDifferentSearch": "Please try a different search query.",
"emptyState": {

View File

@@ -573,7 +573,8 @@ describe('useMediaAssetActions', () => {
expect(mockDownloadFile).not.toHaveBeenCalled()
expect(mockCreateAssetExport).toHaveBeenCalledWith({
job_ids: ['job1'],
naming_strategy: 'preserve'
naming_strategy: 'preserve',
include_previews: true
})
expect(mockTrackExport).toHaveBeenCalledWith('test-task-id')

View File

@@ -201,7 +201,8 @@ export function useMediaAssetActions() {
...(Object.keys(jobAssetNameFilters).length > 0
? { job_asset_name_filters: jobAssetNameFilters }
: {}),
naming_strategy: namingStrategy
naming_strategy: namingStrategy,
include_previews: true
})
assetExportStore.trackExport(result.task_id)

View File

@@ -50,6 +50,7 @@ interface AssetExportOptions {
| 'preserve'
| 'asset_id'
job_asset_name_filters?: Record<string, string[]>
include_previews?: boolean
}
/**

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import {
capturePreservedQuery,
clearPreservedQuery,
getPreservedQueryParam,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
@@ -29,6 +30,22 @@ describe('preservedQueryManager', () => {
expect(sessionStorage.getItem('Comfy.PreservedQuery.template')).toBeTruthy()
})
it('reads a preserved query param by key', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
expect(getPreservedQueryParam(NAMESPACE, 'source')).toBeUndefined()
})
it('hydrates from sessionStorage when reading a param', () => {
sessionStorage.setItem(
'Comfy.PreservedQuery.template',
JSON.stringify({ template: 'flux' })
)
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
})
it('hydrates cached payload from sessionStorage once', () => {
sessionStorage.setItem(
'Comfy.PreservedQuery.template',

View File

@@ -87,6 +87,14 @@ export const capturePreservedQuery = (
writeToStorage(namespace, payload)
}
export function getPreservedQueryParam(
namespace: string,
key: string
): string | undefined {
hydratePreservedQuery(namespace)
return preservedQueries.get(namespace)?.[key]
}
export const mergePreservedQueryIntoQuery = (
namespace: string,
query?: LocationQueryRaw

View File

@@ -2,6 +2,7 @@ export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template',
INVITE: 'invite',
SHARE: 'share',
SHARE_AUTH: 'share_auth',
CREATE_WORKSPACE: 'create_workspace',
OAUTH: 'oauth'
} as const

View File

@@ -6,6 +6,7 @@ import type {
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ShareLinkOpenedMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
@@ -19,6 +20,7 @@ import type {
PageViewMetadata,
PageVisibilityMetadata,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -182,6 +184,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackShareFlow?.(metadata))
}
trackShareLinkOpened(metadata: ShareLinkOpenedMetadata): void {
this.dispatch((provider) => provider.trackShareLinkOpened?.(metadata))
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata))
}
@@ -240,6 +246,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackExecutionSuccess?.(metadata))
}
trackSharedWorkflowRun(metadata: SharedWorkflowRunMetadata): void {
this.dispatch((provider) => provider.trackSharedWorkflowRun?.(metadata))
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.dispatch((provider) => provider.trackSettingChanged?.(metadata))
}

View File

@@ -322,13 +322,31 @@ describe('GtmTelemetryProvider', () => {
const provider = createInitializedProvider()
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode'
source: 'app_mode',
share_id: 'share-1'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'share_flow',
step: 'link_copied',
source: 'app_mode'
})
expect(lastDataLayerEntry()).not.toHaveProperty('share_id')
})
it('omits share_id from workflow import events', () => {
const provider = createInitializedProvider()
provider.trackWorkflowImported({
missing_node_count: 0,
missing_node_types: [],
open_source: 'shared_url',
share_id: 'share-1'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'workflow_import',
open_source: 'shared_url'
})
expect(lastDataLayerEntry()).not.toHaveProperty('share_id')
})
it('pushes normalized email inside the auth event payload', () => {
@@ -338,7 +356,8 @@ describe('GtmTelemetryProvider', () => {
method: 'email',
is_new_user: true,
user_id: 'uid-123',
email: ' Test@Example.com '
email: ' Test@Example.com ',
share_id: 'share-1'
})
const dl = window.dataLayer as Record<string, unknown>[]
@@ -351,6 +370,7 @@ describe('GtmTelemetryProvider', () => {
email: 'test@example.com'
}
})
expect(authEvent).not.toHaveProperty('share_id')
expect(
dl.some((entry) => 'user_data' in entry && !('event' in entry))
).toBe(false)

View File

@@ -408,6 +408,45 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
})
)
})
it('omits share_id from existing Mixpanel events', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
provider.trackAuth({ method: 'google', share_id: 'share-1' })
provider.trackWorkflowImported({
missing_node_count: 0,
missing_node_types: [],
open_source: 'shared_url',
share_id: 'share-1'
})
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode',
share_id: 'share-1'
})
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{ method: 'google' }
)
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.WORKFLOW_IMPORTED,
{
missing_node_count: 0,
missing_node_types: [],
open_source: 'shared_url'
}
)
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.SHARE_FLOW,
{
step: 'link_copied',
source: 'app_mode'
}
)
})
})
describe('MixpanelTelemetryProvider — topup delegation', () => {

View File

@@ -1,4 +1,5 @@
import type { OverridedMixpanel } from 'mixpanel-browser'
import { omit } from 'es-toolkit'
import { watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
@@ -17,7 +18,6 @@ import type {
CreditTopupMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
@@ -27,6 +27,7 @@ import type {
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -209,7 +210,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
trackAuth(metadata: AuthMetadata): void {
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
this.trackEvent(
TelemetryEvents.USER_AUTH_COMPLETED,
omit(metadata, ['share_id'])
)
}
trackUserLoggedIn(): void {
@@ -356,11 +360,17 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
this.trackEvent(
TelemetryEvents.WORKFLOW_IMPORTED,
omit(metadata, ['share_id'])
)
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
this.trackEvent(
TelemetryEvents.WORKFLOW_OPENED,
omit(metadata, ['share_id'])
)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
@@ -376,7 +386,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
this.trackEvent(TelemetryEvents.SHARE_FLOW, omit(metadata, ['share_id']))
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {

View File

@@ -270,6 +270,35 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('captures share attribution events', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackShareLinkOpened({
share_id: 'share-1',
is_authenticated: true
})
provider.trackSharedWorkflowRun({
job_id: 'job-1',
share_id: 'share-1'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARE_LINK_OPENED,
{
share_id: 'share-1',
is_authenticated: true
}
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARED_WORKFLOW_RUN,
{
job_id: 'job-1',
share_id: 'share-1'
}
)
})
it('captures search queries with surface, query, length, and result count', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()

View File

@@ -14,6 +14,7 @@ import type {
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ShareLinkOpenedMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
@@ -26,6 +27,7 @@ import type {
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -483,6 +485,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
}
trackShareLinkOpened(metadata: ShareLinkOpenedMetadata): void {
this.trackEvent(TelemetryEvents.SHARE_LINK_OPENED, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}
@@ -527,6 +533,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackSharedWorkflowRun(metadata: SharedWorkflowRunMetadata): void {
this.trackEvent(TelemetryEvents.SHARED_WORKFLOW_RUN, metadata)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
}

View File

@@ -25,6 +25,7 @@ export interface AuthMetadata {
is_new_user?: boolean
user_id?: string
email?: string
share_id?: string
referrer_url?: string
utm_source?: string
utm_medium?: string
@@ -116,6 +117,11 @@ export interface ExecutionSuccessMetadata {
jobId: string
}
export interface SharedWorkflowRunMetadata {
job_id: string
share_id: string
}
/**
* Template metadata for workflow tracking
*/
@@ -165,6 +171,7 @@ export interface WorkflowImportMetadata {
| 'template'
| 'shared_url'
| 'unknown'
share_id?: string
}
export interface EnterLinearMetadata {
@@ -189,6 +196,12 @@ type ShareFlowStep =
export interface ShareFlowMetadata {
step: ShareFlowStep
source?: 'app_mode' | 'graph_mode'
share_id?: string
}
export interface ShareLinkOpenedMetadata {
share_id: string
is_authenticated: boolean
}
/**
@@ -477,6 +490,7 @@ export interface TelemetryProvider {
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
trackEnterLinear?(metadata: EnterLinearMetadata): void
trackShareFlow?(metadata: ShareFlowMetadata): void
trackShareLinkOpened?(metadata: ShareLinkOpenedMetadata): void
// Page visibility events
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
@@ -509,6 +523,7 @@ export interface TelemetryProvider {
trackWorkflowExecution?(): void
trackExecutionError?(metadata: ExecutionErrorMetadata): void
trackExecutionSuccess?(metadata: ExecutionSuccessMetadata): void
trackSharedWorkflowRun?(metadata: SharedWorkflowRunMetadata): void
// Settings events
trackSettingChanged?(metadata: SettingChangedMetadata): void
@@ -570,6 +585,7 @@ export const TelemetryEvents = {
WORKFLOW_OPENED: 'app:workflow_opened',
ENTER_LINEAR_MODE: 'app:app_mode_opened',
SHARE_FLOW: 'app:share_flow',
SHARE_LINK_OPENED: 'app:share_link_opened',
// Page Visibility
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
@@ -603,6 +619,7 @@ export const TelemetryEvents = {
EXECUTION_START: 'execution_start',
EXECUTION_ERROR: 'execution_error',
EXECUTION_SUCCESS: 'execution_success',
SHARED_WORKFLOW_RUN: 'app:shared_workflow_run',
// Generic UI Button Click
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
@@ -631,6 +648,7 @@ export type TelemetryEventProperties =
| RunButtonProperties
| ExecutionErrorMetadata
| ExecutionSuccessMetadata
| SharedWorkflowRunMetadata
| CreditTopupMetadata
| WorkflowImportMetadata
| TemplateLibraryMetadata
@@ -649,6 +667,7 @@ export type TelemetryEventProperties =
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata
| ShareLinkOpenedMetadata
| WorkflowSavedMetadata
| DefaultViewSetMetadata
| SubscriptionMetadata

View File

@@ -636,6 +636,71 @@ describe('useWorkflowService', () => {
expect(workflowStore.createNewTemporary).toHaveBeenCalled()
})
it('stores share attribution on shared temporary workflows', async () => {
vi.mocked(workflowStore.getWorkflowByPath).mockReturnValue(null)
const tempWorkflow = createModeTestWorkflow({
path: 'workflows/shared.json'
})
vi.mocked(workflowStore.createNewTemporary).mockReturnValue(tempWorkflow)
vi.mocked(workflowStore.openWorkflow).mockResolvedValue(tempWorkflow)
await useWorkflowService().afterLoadNewGraph(
'shared',
{ nodes: [] } as never,
'share-1'
)
expect(tempWorkflow.shareId).toBe('share-1')
})
it('preserves share attribution on repeated same-path loads', async () => {
existingWorkflow.shareId = 'share-1'
await useWorkflowService().afterLoadNewGraph('repeat', {
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
} as never)
expect(existingWorkflow.shareId).toBe('share-1')
})
it('preserves share attribution on workflow object reloads', async () => {
existingWorkflow.shareId = 'share-1'
await useWorkflowService().afterLoadNewGraph(existingWorkflow, {
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
} as never)
expect(existingWorkflow.shareId).toBe('share-1')
})
it('overwrites share attribution on repeated same-path loads with a new share id', async () => {
existingWorkflow.shareId = 'share-1'
await useWorkflowService().afterLoadNewGraph(
'repeat',
{
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
} as never,
'share-2'
)
expect(existingWorkflow.shareId).toBe('share-2')
})
it('overwrites share attribution on workflow object reloads with a new share id', async () => {
existingWorkflow.shareId = 'share-1'
await useWorkflowService().afterLoadNewGraph(
existingWorkflow,
{
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
} as never,
'share-2'
)
expect(existingWorkflow.shareId).toBe('share-2')
})
})
describe('per-workflow mode switching', () => {

View File

@@ -445,7 +445,8 @@ export const useWorkflowService = () => {
*/
const afterLoadNewGraph = async (
value: string | ComfyWorkflow | null,
workflowData: ComfyWorkflowJSON
workflowData: ComfyWorkflowJSON,
shareId?: string
) => {
const workflowStore = useWorkspaceStore().workflow
const { isAppMode } = useAppMode()
@@ -499,6 +500,9 @@ export const useWorkflowService = () => {
) ?? freshLoadMode
trackIfEnteringApp(loadedWorkflow)
}
if (shareId) {
loadedWorkflow.shareId = shareId
}
loadedWorkflow.changeTracker.reset(workflowData)
loadedWorkflow.changeTracker.restore()
return
@@ -510,12 +514,18 @@ export const useWorkflowService = () => {
workflowData
)
tempWorkflow.initialMode = freshLoadMode
if (shareId) {
tempWorkflow.shareId = shareId
}
trackIfEnteringApp(tempWorkflow)
await workflowStore.openWorkflow(tempWorkflow)
return
}
const loadedWorkflow = await workflowStore.openWorkflow(value)
if (shareId) {
loadedWorkflow.shareId = shareId
}
if (loadedWorkflow.initialMode === undefined) {
loadedWorkflow.initialMode = freshLoadMode
trackIfEnteringApp(loadedWorkflow)

View File

@@ -55,6 +55,7 @@ export class ComfyWorkflow extends UserFile {
* Takes precedence over initialMode when present.
*/
activeMode: AppMode | null = null
shareId?: string
/**
* @param options The path, modified, and size of the workflow.
* Note: path is the full path, including the 'workflows/' prefix.

View File

@@ -30,8 +30,9 @@ import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useTelemetry } from '@/platform/telemetry'
const { url } = defineProps<{
const { url, shareId } = defineProps<{
url: string
shareId: string
}>()
const { copyToClipboard } = useCopyToClipboard()
@@ -43,7 +44,8 @@ async function handleCopy() {
copied.value = true
useTelemetry()?.trackShareFlow({
step: 'link_copied',
source: isAppMode.value ? 'app_mode' : 'graph_mode'
source: isAppMode.value ? 'app_mode' : 'graph_mode',
share_id: shareId
})
}
</script>

View File

@@ -23,6 +23,14 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
const mockTrackShareFlow = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackShareFlow: mockTrackShareFlow
})
}))
const mockToast = vi.hoisted(() => ({ add: vi.fn() }))
vi.mock('primevue/usetoast', () => ({
@@ -112,7 +120,10 @@ const i18n = createI18n({
saveButton: 'Save workflow',
createLinkButton: 'Create link',
creatingLink: 'Creating link...',
copyLink: 'Copy link',
linkCopied: 'Copied',
checkingAssets: 'Checking assets...',
shareUrlLabel: 'Share URL',
successDescription: 'Anyone with this link...',
hasChangesDescription: 'You have made changes...',
updateLinkButton: 'Update link',
@@ -373,6 +384,31 @@ describe('ShareWorkflowDialogContent', () => {
'workflows/test.json',
initialShareableAssets
)
expect(mockTrackShareFlow).toHaveBeenCalledWith({
step: 'link_created',
source: 'graph_mode',
share_id: 'test-123'
})
})
it('tracks copied share link with share id', async () => {
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
shareId: 'copy-123',
shareUrl: 'https://comfy.org/shared/copy-123',
publishedAt: new Date('2026-01-15')
})
renderComponent()
await flushPromises()
await userEvent.click(screen.getByRole('button', { name: /Copy link/i }))
expect(mockTrackShareFlow).toHaveBeenCalledWith({
step: 'link_copied',
source: 'graph_mode',
share_id: 'copy-123'
})
})
it('shows update button when workflow was saved after last publish', async () => {

View File

@@ -112,7 +112,10 @@
</template>
<template v-if="dialogState === 'shared' && publishResult">
<ShareUrlCopyField :url="publishResult.shareUrl" />
<ShareUrlCopyField
:url="publishResult.shareUrl"
:share-id="publishResult.shareId"
/>
<div class="flex flex-col gap-1">
<p
v-if="publishResult.publishedAt"
@@ -437,7 +440,8 @@ const {
acknowledged.value = false
useTelemetry()?.trackShareFlow({
step: 'link_created',
source: getShareSource()
source: getShareSource(),
share_id: result.shareId
})
return result

View File

@@ -5,6 +5,7 @@ import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composab
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
const preservedQueryMocks = vi.hoisted(() => ({
capturePreservedQuery: vi.fn(),
clearPreservedQuery: vi.fn(),
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn()
@@ -28,6 +29,20 @@ vi.mock('vue-router', () => ({
}))
const mockImportPublishedAssets = vi.fn()
const mockIsLoggedIn = vi.hoisted(() => ({ value: false }))
const mockTrackShareLinkOpened = vi.hoisted(() => vi.fn())
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
isLoggedIn: mockIsLoggedIn
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackShareLinkOpened: mockTrackShareLinkOpened
})
}))
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
SharedWorkflowLoadError: class extends Error {
@@ -174,6 +189,7 @@ describe('useSharedWorkflowUrlLoader', () => {
beforeEach(() => {
vi.resetAllMocks()
mockQueryParams = {}
mockIsLoggedIn.value = false
mockDialogStack.length = 0
mockShowLayoutDialog.mockImplementation(createDialogInstance)
mockUpdateDialog.mockImplementation(
@@ -213,6 +229,7 @@ describe('useSharedWorkflowUrlLoader', () => {
expect(loaded).toBe('not-present')
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
expect(mockLoadGraphData).not.toHaveBeenCalled()
expect(mockTrackShareLinkOpened).not.toHaveBeenCalled()
})
it('opens dialog immediately with shareId and loads graph on confirm', async () => {
@@ -234,7 +251,16 @@ describe('useSharedWorkflowUrlLoader', () => {
true,
true,
'Test Workflow',
{ openSource: 'shared_url' }
{ openSource: 'shared_url', shareId: 'share-id-1' }
)
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
share_id: 'share-id-1',
is_authenticated: false
})
expect(preservedQueryMocks.capturePreservedQuery).toHaveBeenCalledWith(
'share_auth',
{ share: 'share-id-1' },
['share']
)
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
@@ -242,6 +268,24 @@ describe('useSharedWorkflowUrlLoader', () => {
)
})
it('does not capture share auth attribution for authenticated users', async () => {
mockQueryParams = { share: 'share-id-1' }
mockIsLoggedIn.value = true
mockShowLayoutDialog.mockImplementation(() => {
resolveDialogWithConfirm(makePayload())
})
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
const loaded = await loadSharedWorkflowFromUrl()
expect(loaded).toBe('loaded')
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
share_id: 'share-id-1',
is_authenticated: true
})
expect(preservedQueryMocks.capturePreservedQuery).not.toHaveBeenCalled()
})
it('hides template selector when user confirms opening shared workflow', async () => {
mockQueryParams = { share: 'share-id-1' }
mockShowLayoutDialog.mockImplementation(() => {
@@ -301,6 +345,9 @@ describe('useSharedWorkflowUrlLoader', () => {
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'share'
)
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'share_auth'
)
})
it('does not hide template selector when user cancels shared workflow dialog', async () => {
@@ -411,7 +458,7 @@ describe('useSharedWorkflowUrlLoader', () => {
true,
true,
'Test Workflow',
{ openSource: 'shared_url' }
{ openSource: 'shared_url', shareId: 'share-id-1' }
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
@@ -541,7 +588,7 @@ describe('useSharedWorkflowUrlLoader', () => {
true,
true,
'Open shared workflow',
{ openSource: 'shared_url' }
{ openSource: 'shared_url', shareId: 'share-id-1' }
)
})
})

View File

@@ -2,10 +2,13 @@ import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useTelemetry } from '@/platform/telemetry'
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
import {
capturePreservedQuery,
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
@@ -41,6 +44,7 @@ export function useSharedWorkflowUrlLoader() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
const { isLoggedIn } = useCurrentUser()
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
function isValidParameter(param: string): boolean {
@@ -140,9 +144,22 @@ export function useSharedWorkflowUrlLoader() {
return 'failed'
}
useTelemetry()?.trackShareLinkOpened({
share_id: shareParam,
is_authenticated: isLoggedIn.value
})
if (!isLoggedIn.value) {
capturePreservedQuery(
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
{ share: shareParam },
['share']
)
}
const result = await showOpenSharedWorkflowDialog(shareParam)
if (result.action === 'cancel') {
clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.SHARE_AUTH)
clearShareIntent()
return 'cancelled'
}
@@ -182,7 +199,8 @@ export function useSharedWorkflowUrlLoader() {
true,
workflowName,
{
openSource: 'shared_url'
openSource: 'shared_url',
shareId: payload.shareId
}
)
} catch (error) {

View File

@@ -1138,6 +1138,7 @@ export class ComfyApp {
options: {
checkForRerouteMigration?: boolean
openSource?: WorkflowOpenSource
shareId?: string
deferWarnings?: boolean
skipAssetScans?: boolean
silentAssetErrors?: boolean
@@ -1146,6 +1147,7 @@ export class ComfyApp {
const {
checkForRerouteMigration = false,
openSource,
shareId,
deferWarnings = false,
skipAssetScans = false,
silentAssetErrors = false
@@ -1423,19 +1425,24 @@ export class ComfyApp {
missingNodeTypes
)
const effectiveShareId =
shareId ??
(workflow instanceof ComfyWorkflow ? workflow.shareId : undefined)
const telemetryPayload = {
missing_node_count: missingNodeTypes.length,
missing_node_types: missingNodeTypes.map((node) =>
typeof node === 'string' ? node : node.type
),
missing_node_packs: groupMissingNodesByPack(missingNodeTypes),
open_source: openSource ?? 'unknown'
open_source: openSource ?? 'unknown',
...(effectiveShareId ? { share_id: effectiveShareId } : {})
}
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
useTelemetry()?.trackWorkflowImported(telemetryPayload)
await useWorkflowService().afterLoadNewGraph(
workflow,
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON,
effectiveShareId
)
// If the canvas was not visible and we're a fresh load, resize the canvas and fit the view

View File

@@ -7,8 +7,13 @@ import { isAbortError } from '@/utils/typeGuardUtil'
const API_BASE_URL = 'https://api.comfy.org'
// Without a timeout a hung socket (e.g. no internet, captive portal) never
// rejects, leaving callers stuck in their loading state indefinitely.
const REQUEST_TIMEOUT_MS = 10_000
const registryApiClient = axios.create({
baseURL: API_BASE_URL,
timeout: REQUEST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json'
},

View File

@@ -6,6 +6,8 @@ import type { Mock } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
import { createTestingPinia } from '@pinia/testing'
@@ -139,6 +141,8 @@ describe('useAuthStore', () => {
beforeEach(() => {
vi.resetAllMocks()
sessionStorage.clear()
clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.SHARE_AUTH)
// Setup dialog service mock
vi.mocked(useDialogService, { partial: true }).mockReturnValue({
@@ -656,6 +660,30 @@ describe('useAuthStore', () => {
)
}
)
it('includes preserved share id on new-user social auth', async () => {
sessionStorage.setItem(
'Comfy.PreservedQuery.share_auth',
JSON.stringify({ share: 'share-1' })
)
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
isNewUser: true,
providerId: 'google.com',
profile: null
})
await store.loginWithGoogle()
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({
is_new_user: true,
share_id: 'share-1'
})
)
expect(
sessionStorage.getItem('Comfy.PreservedQuery.share_auth')
).toBeNull()
})
})
})

View File

@@ -23,6 +23,11 @@ import { useFirebaseAuth } from 'vuefire'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import {
clearPreservedQuery,
getPreservedQueryParam
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
@@ -97,6 +102,15 @@ export const useAuthStore = defineStore('auth', () => {
const userEmail = computed(() => currentUser.value?.email)
const userId = computed(() => currentUser.value?.uid)
function getShareAuthMetadata() {
const shareId = getPreservedQueryParam(
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
'share'
)
if (shareId) clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.SHARE_AUTH)
return shareId ? { share_id: shareId } : {}
}
// Get auth from VueFire and listen for auth state changes
// From useFirebaseAuth docs:
// Retrieves the Firebase Auth instance. Returns `null` on the server.
@@ -333,7 +347,8 @@ export const useAuthStore = defineStore('auth', () => {
method: 'email',
is_new_user: false,
user_id: result.user.uid,
email: result.user.email ?? undefined
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
}
@@ -355,7 +370,8 @@ export const useAuthStore = defineStore('auth', () => {
method: 'email',
is_new_user: true,
user_id: result.user.uid,
email: result.user.email ?? undefined
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
}
@@ -377,7 +393,8 @@ export const useAuthStore = defineStore('auth', () => {
is_new_user:
options?.isNewUser || additionalUserInfo?.isNewUser || false,
user_id: result.user.uid,
email: result.user.email ?? undefined
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
}
@@ -399,7 +416,8 @@ export const useAuthStore = defineStore('auth', () => {
is_new_user:
options?.isNewUser || additionalUserInfo?.isNewUser || false,
user_id: result.user.uid,
email: result.user.email ?? undefined
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
}

View File

@@ -6,6 +6,7 @@ import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import type * as DistributionTypes from '@/platform/distribution/types'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
@@ -15,12 +16,18 @@ const {
mockNodeExecutionIdToNodeLocatorId,
mockNodeIdToNodeLocatorId,
mockNodeLocatorIdToNodeExecutionId,
mockShowTextPreview
mockShowTextPreview,
mockTrackExecutionError,
mockTrackExecutionSuccess,
mockTrackSharedWorkflowRun
} = vi.hoisted(() => ({
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
mockNodeIdToNodeLocatorId: vi.fn(),
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
mockShowTextPreview: vi.fn()
mockShowTextPreview: vi.fn(),
mockTrackExecutionError: vi.fn(),
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
}))
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -40,6 +47,21 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
}
})
vi.mock('@/platform/distribution/types', async () => ({
...(await vi.importActual<typeof DistributionTypes>(
'@/platform/distribution/types'
)),
isCloud: true
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackExecutionError: mockTrackExecutionError,
trackExecutionSuccess: mockTrackExecutionSuccess,
trackSharedWorkflowRun: mockTrackSharedWorkflowRun
})
}))
// Remove any previous global types
declare global {
interface Window {}
@@ -1085,6 +1107,50 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.activeJobId).toBeNull()
expect(store.queuedJobs['job-1']).toBeUndefined()
})
it('tracks shared workflow run when the queued workflow has share attribution', () => {
const workflow = createQueuedWorkflow()
workflow.shareId = 'share-1'
store.storeJob({
nodes: ['a'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA')
},
workflow
})
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackExecutionSuccess).toHaveBeenCalledWith({
jobId: 'job-1'
})
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1'
})
})
it('tracks shared workflow run from the success event job', () => {
const workflow = createQueuedWorkflow()
workflow.shareId = 'share-1'
store.storeJob({
nodes: ['a'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA')
},
workflow
})
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1'
})
})
})
describe('executing', () => {
@@ -1251,6 +1317,7 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
b: { title: 'Node B', type: 'NodeB' }
})
expect(store.queuedJobs['job-1']?.workflow).toStrictEqual(workflow)
expect(store.queuedJobs['job-1']?.shareId).toBeUndefined()
expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
'/workflows/foo.json'

View File

@@ -55,6 +55,11 @@ interface QueuedJob {
* This stays stable even if the user switches workflows or edits the canvas.
*/
nodeLookup?: Record<string, ExecutionNodeInfo>
/**
* Share attribution snapshotted at queue time. Read this instead of
* `workflow.shareId`, which can gain attribution after the job was queued.
*/
shareId?: string
}
function buildExecutionNodeLookup(
@@ -295,12 +300,20 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
if (isCloud && activeJobId.value) {
useTelemetry()?.trackExecutionSuccess({
jobId: activeJobId.value
})
}
const jobId = e.detail.prompt_id
const queuedJob = queuedJobs.value[jobId]
if (isCloud && queuedJob) {
const telemetry = useTelemetry()
telemetry?.trackExecutionSuccess({
jobId
})
if (queuedJob.shareId) {
telemetry?.trackSharedWorkflowRun({
job_id: jobId,
share_id: queuedJob.shareId
})
}
}
resetExecutionState(jobId)
}
@@ -580,6 +593,7 @@ export const useExecutionStore = defineStore('execution', () => {
}
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.workflow = workflow
queuedJob.shareId = workflow?.shareId
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
if (wid) {
jobIdToWorkflowId.value.set(id, wid)

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useScroll, whenever } from '@vueuse/core'
import Panel from 'primevue/panel'
import TabMenu from 'primevue/tabmenu'
@@ -40,10 +41,7 @@ const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
const isTaskInProgress = (index: number) => {
const log = focusedLogs.value[index]
if (!log) return false
const isTaskInProgress = (taskId: string) => {
const taskQueue = comfyManagerStore.taskQueue
if (!taskQueue) return false
@@ -52,7 +50,13 @@ const isTaskInProgress = (index: number) => {
...(taskQueue.pending_queue || [])
]
return allQueueTasks.some((task) => task.ui_id === log.taskId)
return allQueueTasks.some((task) => task.ui_id === taskId)
}
function taskStatusLabel(taskId: string): string {
if (isTaskInProgress(taskId)) return t('g.inProgress')
if (comfyManagerStore.isTaskFailed(taskId)) return t('manager.failed')
return t('g.completedWithCheckmark')
}
const completedTasksCount = computed(() => {
@@ -190,12 +194,16 @@ onBeforeUnmount(() => {
<div class="flex w-full items-center justify-between py-2">
<div class="flex flex-col text-sm/normal font-medium">
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isTaskInProgress(index)
? t('g.inProgress')
: t('g.completedWithCheckmark')
}}
<span
:class="
cn(
'text-muted',
comfyManagerStore.isTaskFailed(log.taskId) &&
'text-danger'
)
"
>
{{ taskStatusLabel(log.taskId) }}
</span>
</div>
</div>
@@ -229,6 +237,17 @@ onBeforeUnmount(() => {
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(
errorMessage, errorIndex
) in comfyManagerStore.getTaskErrorMessages(log.taskId)"
:key="`error-${errorIndex}`"
class="text-danger"
>
<pre class="wrap-break-word whitespace-pre-wrap">{{
errorMessage
}}</pre>
</div>
<div
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"

View File

@@ -114,6 +114,8 @@
v-else-if="displayPacks.length === 0"
:title="emptyStateTitle"
:message="emptyStateMessage"
:button-label="searchError ? $t('manager.retry') : undefined"
@action="() => void retrySearch()"
/>
<div v-else class="size-full" @click="handleGridContainerClick">
<VirtualGrid
@@ -344,6 +346,8 @@ const {
searchQuery,
pageNumber,
isLoading: isSearchLoading,
error: searchError,
retry: retrySearch,
searchResults,
searchMode,
sortField,
@@ -434,9 +438,15 @@ const isManagerErrorRelevant = computed(() => {
)
})
// The registry search failing (e.g. offline) is also a connection error worth
// surfacing, and unlike the manager-store error it can be retried in place.
const hasConnectionError = computed(
() => isManagerErrorRelevant.value || !!searchError.value
)
// Empty state messages based on current tab and search state
const emptyStateTitle = computed(() => {
if (isManagerErrorRelevant.value) return t('manager.errorConnecting')
if (hasConnectionError.value) return t('manager.errorConnecting')
if (searchQuery.value) return t('manager.noResultsFound')
const tabId = selectedTab.value?.id
@@ -448,7 +458,7 @@ const emptyStateTitle = computed(() => {
})
const emptyStateMessage = computed(() => {
if (isManagerErrorRelevant.value) return t('manager.tryAgainLater')
if (hasConnectionError.value) return t('manager.tryAgainLater')
if (searchQuery.value) {
const baseMessage = t('manager.tryDifferentSearch')
if (isLegacyManagerSearch.value) {
@@ -475,6 +485,9 @@ const onClickWarningLink = () => {
}
const isLoading = computed(() => {
// A failed search must not read as "still loading" -- otherwise the spinner
// runs forever (e.g. offline) instead of showing the error placeholder.
if (searchError.value) return false
if (isSearchLoading.value) return searchResults.value.length === 0
if (isTabLoading.value) return true
return isInitialLoad.value

View File

@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { NodePackSearchProvider } from '@/types/searchServiceTypes'
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
vi.mock('@/services/gateway/registrySearchGateway')
function mockGateway(searchPacks: NodePackSearchProvider['searchPacks']) {
vi.mocked(useRegistrySearchGateway).mockReturnValue({
searchPacks,
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
})
}
describe('useRegistrySearch', () => {
beforeEach(() => {
// Suppress the immediate debounced search so each test drives the search
// explicitly via retry(); pending timers stay queued and never fire.
vi.useFakeTimers()
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
it('clears loading and records the error when the search fails', async () => {
const searchPacks = vi
.fn()
.mockRejectedValue(new Error('All search providers failed'))
mockGateway(searchPacks)
const { isLoading, error, retry } = useRegistrySearch()
await retry()
// The bug: without try/finally, isLoading stayed true forever -> infinite spinner.
expect(isLoading.value).toBe(false)
expect(error.value).toBe('All search providers failed')
})
it('recovers and clears the error on a successful retry', async () => {
const searchPacks = vi.fn().mockRejectedValue(new Error('offline'))
mockGateway(searchPacks)
const { error, searchResults, retry } = useRegistrySearch()
await retry()
expect(error.value).toBe('offline')
searchPacks.mockResolvedValue({
nodePacks: [{ id: 'a', name: 'Pack A' }],
querySuggestions: []
})
await retry()
expect(error.value).toBeNull()
expect(searchResults.value).toHaveLength(1)
})
})

View File

@@ -33,6 +33,7 @@ export function useRegistrySearch(
} = options
const isLoading = ref(false)
const error = ref<string | null>(null)
const sortField = ref<string>(initialSortField)
const searchMode = ref<SearchMode>(initialSearchMode)
const pageSize = ref(DEFAULT_PAGE_SIZE)
@@ -52,43 +53,51 @@ export function useRegistrySearch(
const updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
error.value = null
if (!options.append) {
pageNumber.value = 0
}
const { nodePacks, querySuggestions } = await searchPacks(
searchQuery.value,
{
pageSize: pageSize.value,
pageNumber: pageNumber.value,
restrictSearchableAttributes: searchAttributes.value
}
)
let sortedPacks = nodePacks
// Results are sorted by the default field to begin with -- so don't manually sort again
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
// Get the sort direction from the provider's sortable fields
const sortableFields = getSortableFields()
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
const direction = fieldConfig?.direction || 'desc'
sortedPacks = orderBy(
nodePacks,
[(pack) => getSortValue(pack, sortField.value)],
[direction]
try {
const { nodePacks, querySuggestions } = await searchPacks(
searchQuery.value,
{
pageSize: pageSize.value,
pageNumber: pageNumber.value,
restrictSearchableAttributes: searchAttributes.value
}
)
}
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
} else {
searchResults.value = sortedPacks
let sortedPacks = nodePacks
// Results are sorted by the default field to begin with -- so don't manually sort again
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
// Get the sort direction from the provider's sortable fields
const sortableFields = getSortableFields()
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
const direction = fieldConfig?.direction || 'desc'
sortedPacks = orderBy(
nodePacks,
[(pack) => getSortValue(pack, sortField.value)],
[direction]
)
}
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
} else {
searchResults.value = sortedPacks
}
suggestions.value = querySuggestions
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
isLoading.value = false
}
suggestions.value = querySuggestions
isLoading.value = false
}
const retry = () => updateSearchResults({ append: false })
const onQueryChange = () => void updateSearchResults({ append: false })
const onPageChange = () => {
if (pageNumber.value === 0) return
@@ -108,6 +117,8 @@ export function useRegistrySearch(
return {
isLoading,
error,
retry,
pageNumber,
pageSize,
sortField,

View File

@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
const { mockClient } = vi.hoisted(() => ({
mockClient: { get: vi.fn(), post: vi.fn() }
}))
vi.mock('axios', () => ({
default: {
create: () => mockClient,
isAxiosError: (e: unknown): boolean =>
!!e &&
typeof e === 'object' &&
(e as { isAxiosError?: boolean }).isAxiosError === true
}
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (p: string) => p,
clientId: 'test-client',
initialClientId: 'test-client'
}
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({ isNewManagerUI: ref(true) })
}))
function axiosError(status: number, data?: { message: string }) {
return { isAxiosError: true, response: { status, data } }
}
describe('useComfyManagerService error messages', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('surfaces the backend security message on a 403 instead of the generic fallback', async () => {
const backendMessage =
"ERROR: To use this action, '--listen' must be set to a local IP and security_level must be 'normal-' or lower."
mockClient.post.mockRejectedValue(
axiosError(403, { message: backendMessage })
)
const service = useComfyManagerService()
await service.installPack({
id: 'some-pack',
version: '1.0.0',
selected_version: '1.0.0',
mode: 'remote',
channel: 'default'
})
expect(service.error.value).toBe(backendMessage)
})
it('falls back to the generic security message when the 403 has no body', async () => {
mockClient.post.mockRejectedValue(axiosError(403))
const service = useComfyManagerService()
await service.installPack({
id: 'some-pack',
version: '1.0.0',
selected_version: '1.0.0',
mode: 'remote',
channel: 'default'
})
expect(service.error.value).toContain('security error has occurred')
})
})

View File

@@ -38,8 +38,13 @@ enum ManagerRoute {
QUEUE_TASK = 'manager/queue/task'
}
// Without a timeout a hung socket (e.g. no internet, captive portal) never
// rejects, leaving callers stuck in their loading state indefinitely.
const REQUEST_TIMEOUT_MS = 10_000
const managerApiClient = axios.create({
baseURL: api.apiURL('/v2/'),
timeout: REQUEST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json'
}
@@ -74,14 +79,18 @@ export const useComfyManagerService = () => {
} else {
const axiosError = err as AxiosError<{ message: string }>
const status = axiosError.response?.status
if (status && routeSpecificErrors?.[status]) {
const backendMessage = axiosError.response?.data?.message
// Prefer the backend's message: ComfyUI-Manager returns actionable,
// security-aware text (e.g. which security_level/--listen is required)
// that is far more useful than our generic per-status fallbacks.
if (backendMessage) {
message = backendMessage
} else if (status && routeSpecificErrors?.[status]) {
message = routeSpecificErrors[status]
} else if (status === 404) {
message = 'Could not connect to ComfyUI-Manager'
} else {
message =
axiosError.response?.data?.message ??
`${context} failed with status ${status}`
message = `${context} failed with status ${status}`
}
}

View File

@@ -409,6 +409,51 @@ describe('useComfyManagerStore', () => {
})
})
describe('task failure surfacing', () => {
type TaskHistoryItem = ManagerComponents['schemas']['TaskHistoryItem']
const historyItem = (
uiId: string,
statusStr: 'success' | 'error' | 'skip',
messages: string[]
): TaskHistoryItem => ({
ui_id: uiId,
client_id: 'client',
kind: 'install',
result: statusStr === 'success' ? 'success' : 'failed',
timestamp: '2024-01-01T00:00:00Z',
status: {
status_str: statusStr,
completed: statusStr === 'success',
messages
}
})
it('flags an errored task as failed and surfaces its messages', async () => {
const store = useComfyManagerStore()
const reason =
"ERROR: To use this action, '--listen' must be set and security_level must be 'normal-' or lower."
store.taskHistory = { 'task-1': historyItem('task-1', 'error', [reason]) }
await nextTick()
expect(store.isTaskFailed('task-1')).toBe(true)
expect(store.getTaskErrorMessages('task-1')).toEqual([reason])
})
it('does not surface messages for a successful task', async () => {
const store = useComfyManagerStore()
store.taskHistory = {
'task-2': historyItem('task-2', 'success', ['Installed successfully'])
}
await nextTick()
expect(store.isTaskFailed('task-2')).toBe(false)
expect(store.getTaskErrorMessages('task-2')).toEqual([])
})
})
describe('refreshInstalledList with pack ID normalization', () => {
it('normalizes pack IDs by removing version suffixes', async () => {
const mockPacks = {

View File

@@ -115,6 +115,18 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
{ deep: true }
)
const isTaskFailed = (taskId: string): boolean =>
failedTasksIds.value.includes(taskId)
// The actionable reason a task failed (e.g. the security_level/--listen
// restriction ComfyUI-Manager reports on a blocked install) lives in task
// history, not the streamed server logs -- which stay empty when the request
// is rejected before the task ever runs. Surface it so the failure isn't silent.
const getTaskErrorMessages = (taskId: string): string[] =>
isTaskFailed(taskId)
? (taskHistory.value[taskId]?.status?.messages ?? [])
: []
const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id
const isInstalledPackId = (packName: NodePackId | undefined): boolean =>
@@ -384,6 +396,8 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
failedTasksIds,
succeededTasksLogs,
failedTasksLogs,
isTaskFailed,
getTaskErrorMessages,
managerQueue,
// Pack actions