feat: wire comfyhub core publish and profile flows (WIP)

- Add ComfyHub service layer (upload-url, PUT upload, profile, publish)
- Migrate profile gate to new /hub/profiles endpoints + upload token flow
- Add publish submission composable (media upload + payload assembly)
- Wire publish dialog/wizard/footer with async publish, loading, error handling
- Add unit tests for service, profile gate, submission, wizard, dialog
- Add implementation plans for core wiring and figma alignment

Amp-Thread-ID: https://ampcode.com/threads/T-019ce916-b24f-70e6-880d-e57d918c7a12
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-03-13 16:28:45 -07:00
parent 7131c274f3
commit e8b264d2ac
13 changed files with 1052 additions and 219 deletions

View File

@@ -10,6 +10,7 @@ const mockGoNext = vi.hoisted(() => vi.fn())
const mockGoBack = vi.hoisted(() => vi.fn())
const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
@@ -48,12 +49,22 @@ vi.mock(
})
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubPublishSubmission',
() => ({
useComfyHubPublishSubmission: () => ({
submitToComfyHub: mockSubmitToComfyHub
})
})
)
describe('ComfyHubPublishDialog', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockFetchProfile.mockResolvedValue(null)
mockSubmitToComfyHub.mockResolvedValue(undefined)
})
function createWrapper() {
@@ -78,14 +89,16 @@ describe('ComfyHubPublishDialog', () => {
},
ComfyHubPublishWizardContent: {
template:
'<div><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /></div>',
'<div :data-is-publishing="$props.isPublishing"><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /><button data-testid="publish" @click="$props.onPublish()" /></div>',
props: [
'currentStep',
'formData',
'isFirstStep',
'isLastStep',
'isPublishing',
'onGoNext',
'onGoBack',
'onPublish',
'onRequireProfile',
'onGateComplete',
'onGateClose'
@@ -136,4 +149,15 @@ describe('ComfyHubPublishDialog', () => {
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
expect(onClose).not.toHaveBeenCalled()
})
it('closes dialog after successful publish', async () => {
const wrapper = createWrapper()
await flushPromises()
await wrapper.find('[data-testid="publish"]').trigger('click')
await flushPromises()
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
})

View File

@@ -22,24 +22,26 @@
:form-data
:is-first-step
:is-last-step
:is-publishing
:on-update-form-data="updateFormData"
:on-go-next="goNext"
:on-go-back="goBack"
:on-require-profile="handleRequireProfile"
:on-gate-complete="handlePublishGateComplete"
:on-gate-close="handlePublishGateClose"
:on-publish="onClose"
:on-publish="handlePublish"
/>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, provide } from 'vue'
import { onBeforeUnmount, onMounted, provide, ref } from 'vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import ComfyHubPublishNav from '@/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue'
import ComfyHubPublishWizardContent from '@/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue'
import { useComfyHubPublishSubmission } from '@/platform/workflow/sharing/composables/useComfyHubPublishSubmission'
import { useComfyHubPublishWizard } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
@@ -50,6 +52,7 @@ const { onClose } = defineProps<{
}>()
const { fetchProfile } = useComfyHubProfileGate()
const { submitToComfyHub } = useComfyHubPublishSubmission()
const {
currentStep,
formData,
@@ -61,6 +64,7 @@ const {
openProfileCreationStep,
closeProfileCreationStep
} = useComfyHubPublishWizard()
const isPublishing = ref(false)
function handlePublishGateComplete() {
closeProfileCreationStep()
@@ -75,6 +79,20 @@ function handleRequireProfile() {
openProfileCreationStep()
}
async function handlePublish(): Promise<void> {
if (isPublishing.value) {
return
}
isPublishing.value = true
try {
await submitToComfyHub(formData.value)
onClose()
} finally {
isPublishing.value = false
}
}
function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
formData.value = { ...formData.value, ...patch }
}

View File

@@ -14,7 +14,8 @@
v-else
variant="primary"
size="lg"
:disabled="isPublishDisabled"
:disabled="isPublishDisabled || isPublishing"
:loading="isPublishing"
@click="$emit('publish')"
>
<i class="icon-[lucide--upload] size-4" />
@@ -31,6 +32,7 @@ defineProps<{
isFirstStep: boolean
isLastStep: boolean
isPublishDisabled?: boolean
isPublishing?: boolean
}>()
defineEmits<{

View File

@@ -61,6 +61,7 @@ describe('ComfyHubPublishWizardContent', () => {
beforeEach(() => {
vi.clearAllMocks()
onPublish.mockResolvedValue(undefined)
mockCheckProfile.mockResolvedValue(true)
mockHasProfile.value = true
mockFlags.comfyHubProfileGateEnabled = true
@@ -115,8 +116,13 @@ describe('ComfyHubPublishWizardContent', () => {
},
ComfyHubPublishFooter: {
template:
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
props: ['isFirstStep', 'isLastStep', 'isPublishDisabled'],
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled" :data-is-publishing="isPublishing"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
props: [
'isFirstStep',
'isLastStep',
'isPublishDisabled',
'isPublishing'
],
emits: ['publish', 'next', 'back']
}
}
@@ -124,43 +130,19 @@ describe('ComfyHubPublishWizardContent', () => {
})
}
describe('handlePublish — double-click guard', () => {
it('prevents concurrent publish calls', async () => {
let resolveCheck!: (v: boolean) => void
mockCheckProfile.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveCheck = resolve
})
)
function createDeferred<T>() {
let resolve: (value: T) => void = () => {}
let reject: (error: unknown) => void = () => {}
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await publishBtn.trigger('click')
resolveCheck(true)
await flushPromises()
expect(mockCheckProfile).toHaveBeenCalledTimes(1)
expect(onPublish).toHaveBeenCalledTimes(1)
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
})
describe('handlePublish — feature flag bypass', () => {
it('calls onPublish directly when profile gate is disabled', async () => {
mockFlags.comfyHubProfileGateEnabled = false
return { promise, resolve, reject }
}
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(mockCheckProfile).not.toHaveBeenCalled()
expect(onPublish).toHaveBeenCalledOnce()
})
})
describe('handlePublish — profile check routing', () => {
describe('handlePublish - profile check routing', () => {
it('calls onPublish when profile exists', async () => {
mockCheckProfile.mockResolvedValue(true)
@@ -197,20 +179,83 @@ describe('ComfyHubPublishWizardContent', () => {
expect(onRequireProfile).not.toHaveBeenCalled()
})
it('resets guard after checkProfile error so retry is possible', async () => {
mockCheckProfile.mockRejectedValueOnce(new Error('Network error'))
it('calls onPublish directly when profile gate is disabled', async () => {
mockFlags.comfyHubProfileGateEnabled = false
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(mockCheckProfile).not.toHaveBeenCalled()
expect(onPublish).toHaveBeenCalledOnce()
})
})
describe('handlePublish - async submission', () => {
it('prevents duplicate publish submissions while in-flight', async () => {
const publishDeferred = createDeferred<void>()
onPublish.mockReturnValue(publishDeferred.promise)
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledTimes(1)
publishDeferred.resolve(undefined)
await flushPromises()
})
it('calls onPublish and does not close when publish request fails', async () => {
const publishError = new Error('Publish failed')
onPublish.mockRejectedValueOnce(publishError)
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledOnce()
expect(mockToastErrorHandler).toHaveBeenCalledWith(publishError)
expect(onGateClose).not.toHaveBeenCalled()
})
it('shows publish disabled while submitting', async () => {
const publishDeferred = createDeferred<void>()
onPublish.mockReturnValue(publishDeferred.promise)
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).not.toHaveBeenCalled()
mockCheckProfile.mockResolvedValue(true)
const footer = wrapper.find('[data-testid="publish-footer"]')
expect(footer.attributes('data-publish-disabled')).toBe('true')
expect(footer.attributes('data-is-publishing')).toBe('true')
publishDeferred.resolve(undefined)
await flushPromises()
expect(footer.attributes('data-is-publishing')).toBe('false')
})
it('resets guard after publish error so retry is possible', async () => {
onPublish.mockRejectedValueOnce(new Error('Publish failed'))
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledOnce()
onPublish.mockResolvedValueOnce(undefined)
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledTimes(2)
})
})

View File

@@ -53,6 +53,7 @@
:is-first-step
:is-last-step
:is-publish-disabled
:is-publishing="isPublishInFlight"
@back="onGoBack"
@next="onGoNext"
@publish="handlePublish"
@@ -81,6 +82,7 @@ const {
formData,
isFirstStep,
isLastStep,
isPublishing = false,
onGoNext,
onGoBack,
onUpdateFormData,
@@ -93,10 +95,11 @@ const {
formData: ComfyHubPublishFormData
isFirstStep: boolean
isLastStep: boolean
isPublishing?: boolean
onGoNext: () => void
onGoBack: () => void
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void
onPublish: () => void
onPublish: () => Promise<void>
onRequireProfile: () => void
onGateComplete?: () => void
onGateClose?: () => void
@@ -106,22 +109,27 @@ const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { checkProfile, hasProfile } = useComfyHubProfileGate()
const isResolvingPublishAccess = ref(false)
const isPublishInFlight = computed(
() => isPublishing || isResolvingPublishAccess.value
)
const isPublishDisabled = computed(
() => flags.comfyHubProfileGateEnabled && hasProfile.value !== true
() =>
isPublishInFlight.value ||
(flags.comfyHubProfileGateEnabled && hasProfile.value !== true)
)
async function handlePublish() {
if (isResolvingPublishAccess.value) {
return
}
if (!flags.comfyHubProfileGateEnabled) {
onPublish()
if (isResolvingPublishAccess.value || isPublishing) {
return
}
isResolvingPublishAccess.value = true
try {
if (!flags.comfyHubProfileGateEnabled) {
await onPublish()
return
}
let profileExists: boolean
try {
profileExists = await checkProfile()
@@ -131,11 +139,13 @@ async function handlePublish() {
}
if (profileExists) {
onPublish()
await onPublish()
return
}
onRequireProfile()
} catch (error) {
toastErrorHandler(error)
} finally {
isResolvingPublishAccess.value = false
}

View File

@@ -2,16 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
const mockFetchApi = vi.hoisted(() => vi.fn())
const mockGetMyProfile = vi.hoisted(() => vi.fn())
const mockRequestAssetUploadUrl = vi.hoisted(() => vi.fn())
const mockUploadFileToPresignedUrl = vi.hoisted(() => vi.fn())
const mockCreateProfile = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockResolvedUserInfo = vi.hoisted(() => ({
value: { id: 'user-a' }
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: mockFetchApi
}
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
useComfyHubService: () => ({
getMyProfile: mockGetMyProfile,
requestAssetUploadUrl: mockRequestAssetUploadUrl,
uploadFileToPresignedUrl: mockUploadFileToPresignedUrl,
createProfile: mockCreateProfile
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
@@ -35,19 +41,16 @@ const mockProfile: ComfyHubProfile = {
description: 'A test profile'
}
function mockSuccessResponse(data?: unknown) {
return {
ok: true,
json: async () => data ?? mockProfile
} as Response
}
function mockErrorResponse(status = 500, message = 'Server error') {
return {
ok: false,
status,
json: async () => ({ message })
} as Response
function setCurrentWorkspace(workspaceId: string) {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({
id: workspaceId,
type: 'team',
name: 'Test Workspace',
role: 'owner'
})
)
}
describe('useComfyHubProfileGate', () => {
@@ -56,6 +59,15 @@ describe('useComfyHubProfileGate', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolvedUserInfo.value = { id: 'user-a' }
setCurrentWorkspace('workspace-1')
mockGetMyProfile.mockResolvedValue(mockProfile)
mockRequestAssetUploadUrl.mockResolvedValue({
uploadUrl: 'https://upload.example.com/avatar.png',
publicUrl: 'https://cdn.example.com/avatar.png',
token: 'avatar-token'
})
mockUploadFileToPresignedUrl.mockResolvedValue(undefined)
mockCreateProfile.mockResolvedValue(mockProfile)
// Reset module-level singleton refs
gate = useComfyHubProfileGate()
@@ -66,50 +78,30 @@ describe('useComfyHubProfileGate', () => {
})
describe('fetchProfile', () => {
it('returns mapped profile when API responds ok', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
it('fetches profile from /hub/profiles/me', async () => {
const profile = await gate.fetchProfile()
expect(profile).toEqual(mockProfile)
expect(gate.hasProfile.value).toBe(true)
expect(gate.profile.value).toEqual(mockProfile)
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profile')
expect(mockGetMyProfile).toHaveBeenCalledOnce()
})
it('returns cached profile when already fetched', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
it('reuses cached profile state per user', async () => {
await gate.fetchProfile()
const profile = await gate.fetchProfile()
expect(profile).toEqual(mockProfile)
expect(mockFetchApi).toHaveBeenCalledTimes(1)
})
it('re-fetches profile when force option is enabled', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.fetchProfile()
await gate.fetchProfile({ force: true })
expect(mockGetMyProfile).toHaveBeenCalledTimes(1)
expect(mockFetchApi).toHaveBeenCalledTimes(2)
})
mockResolvedUserInfo.value = { id: 'user-b' }
await gate.fetchProfile()
it('returns null when API responds with error', async () => {
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
const profile = await gate.fetchProfile()
expect(profile).toBeNull()
expect(gate.hasProfile.value).toBe(false)
expect(gate.profile.value).toBeNull()
expect(mockGetMyProfile).toHaveBeenCalledTimes(2)
})
it('sets isFetchingProfile during fetch', async () => {
let resolvePromise: (v: Response) => void
mockFetchApi.mockReturnValue(
new Promise<Response>((resolve) => {
let resolvePromise: (v: ComfyHubProfile | null) => void
mockGetMyProfile.mockReturnValue(
new Promise<ComfyHubProfile | null>((resolve) => {
resolvePromise = resolve
})
)
@@ -117,7 +109,7 @@ describe('useComfyHubProfileGate', () => {
const promise = gate.fetchProfile()
expect(gate.isFetchingProfile.value).toBe(true)
resolvePromise!(mockSuccessResponse())
resolvePromise!(mockProfile)
await promise
expect(gate.isFetchingProfile.value).toBe(false)
@@ -126,7 +118,7 @@ describe('useComfyHubProfileGate', () => {
describe('checkProfile', () => {
it('returns true when API responds ok', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
mockGetMyProfile.mockResolvedValue(mockProfile)
const result = await gate.checkProfile()
@@ -134,105 +126,62 @@ describe('useComfyHubProfileGate', () => {
expect(gate.hasProfile.value).toBe(true)
})
it('returns false when API responds with error', async () => {
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
it('returns false when no profile exists', async () => {
mockGetMyProfile.mockResolvedValue(null)
const result = await gate.checkProfile()
expect(result).toBe(false)
expect(gate.hasProfile.value).toBe(false)
})
it('returns cached value without re-fetching', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.checkProfile()
const result = await gate.checkProfile()
expect(result).toBe(true)
expect(mockFetchApi).toHaveBeenCalledTimes(1)
})
it('clears cached profile state when the authenticated user changes', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.checkProfile()
mockResolvedUserInfo.value = { id: 'user-b' }
await gate.checkProfile()
expect(mockFetchApi).toHaveBeenCalledTimes(2)
})
})
describe('createProfile', () => {
it('sends FormData with required username', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.createProfile({ username: 'testuser' })
const [url, options] = mockFetchApi.mock.calls[0]
expect(url).toBe('/hub/profile')
expect(options.method).toBe('POST')
const body = options.body as FormData
expect(body.get('username')).toBe('testuser')
})
it('includes optional fields when provided', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
const coverImage = new File(['img'], 'cover.png')
it('creates profile with workspace_id and avatar token', async () => {
const profilePicture = new File(['img'], 'avatar.png')
await gate.createProfile({
username: 'testuser',
name: 'Test User',
description: 'Hello',
coverImage,
profilePicture
})
const body = mockFetchApi.mock.calls[0][1].body as FormData
expect(body.get('name')).toBe('Test User')
expect(body.get('description')).toBe('Hello')
expect(body.get('cover_image')).toBe(coverImage)
expect(body.get('profile_picture')).toBe(profilePicture)
})
it('sets profile state on success', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.createProfile({ username: 'testuser' })
expect(gate.hasProfile.value).toBe(true)
expect(gate.profile.value).toEqual(mockProfile)
})
it('returns the created profile', async () => {
mockFetchApi.mockResolvedValue(
mockSuccessResponse({
username: 'testuser',
name: 'Test User',
description: 'A test profile',
cover_image_url: 'https://example.com/cover.png',
profile_picture_url: 'https://example.com/profile.png'
})
)
const profile = await gate.createProfile({ username: 'testuser' })
expect(profile).toEqual({
...mockProfile,
coverImageUrl: 'https://example.com/cover.png',
profilePictureUrl: 'https://example.com/profile.png'
expect(mockCreateProfile).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
username: 'testuser',
displayName: 'Test User',
description: 'Hello',
avatarToken: 'avatar-token'
})
})
it('throws with error message from API response', async () => {
mockFetchApi.mockResolvedValue(mockErrorResponse(400, 'Username taken'))
it('uploads avatar via upload-url + PUT before create', async () => {
const profilePicture = new File(['img'], 'avatar.png', {
type: 'image/png'
})
await expect(gate.createProfile({ username: 'taken' })).rejects.toThrow(
'Username taken'
)
await gate.createProfile({
username: 'testuser',
profilePicture
})
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
filename: 'avatar.png',
contentType: 'image/png'
})
expect(mockUploadFileToPresignedUrl).toHaveBeenCalledWith({
uploadUrl: 'https://upload.example.com/avatar.png',
file: profilePicture,
contentType: 'image/png'
})
const requestCallOrder =
mockRequestAssetUploadUrl.mock.invocationCallOrder
const uploadCallOrder =
mockUploadFileToPresignedUrl.mock.invocationCallOrder
const createCallOrder = mockCreateProfile.mock.invocationCallOrder
expect(requestCallOrder[0]).toBeLessThan(uploadCallOrder[0])
expect(uploadCallOrder[0]).toBeLessThan(createCallOrder[0])
})
})
})

View File

@@ -2,9 +2,9 @@ import { ref } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { zHubProfileResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
// TODO: Migrate to a Pinia store for proper singleton state management
// User-scoped, session-cached profile state (module-level singleton)
@@ -15,14 +15,43 @@ const profile = ref<ComfyHubProfile | null>(null)
const cachedUserId = ref<string | null>(null)
let inflightFetch: Promise<ComfyHubProfile | null> | null = null
function mapHubProfileResponse(payload: unknown): ComfyHubProfile | null {
const result = zHubProfileResponse.safeParse(payload)
return result.success ? result.data : null
function getCurrentWorkspaceId(): string {
const workspaceJson = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
)
if (!workspaceJson) {
throw new Error('Unable to determine current workspace')
}
let workspace: unknown
try {
workspace = JSON.parse(workspaceJson)
} catch {
throw new Error('Unable to determine current workspace')
}
if (
!workspace ||
typeof workspace !== 'object' ||
!('id' in workspace) ||
typeof workspace.id !== 'string' ||
workspace.id.length === 0
) {
throw new Error('Unable to determine current workspace')
}
return workspace.id
}
export function useComfyHubProfileGate() {
const { resolvedUserInfo } = useCurrentUser()
const { toastErrorHandler } = useErrorHandling()
const {
getMyProfile,
requestAssetUploadUrl,
uploadFileToPresignedUrl,
createProfile: createComfyHubProfile
} = useComfyHubService()
function syncCachedProfileWithCurrentUser(): void {
const currentUserId = resolvedUserInfo.value?.id ?? null
@@ -38,14 +67,7 @@ export function useComfyHubProfileGate() {
async function performFetch(): Promise<ComfyHubProfile | null> {
isFetchingProfile.value = true
try {
const response = await api.fetchApi('/hub/profile')
if (!response.ok) {
hasProfile.value = false
profile.value = null
return null
}
const nextProfile = mapHubProfileResponse(await response.json())
const nextProfile = await getMyProfile()
if (!nextProfile) {
hasProfile.value = false
profile.value = null
@@ -95,37 +117,35 @@ export function useComfyHubProfileGate() {
username: string
name?: string
description?: string
coverImage?: File
profilePicture?: File
}): Promise<ComfyHubProfile> {
syncCachedProfileWithCurrentUser()
const formData = new FormData()
formData.append('username', data.username)
if (data.name) formData.append('name', data.name)
if (data.description) formData.append('description', data.description)
if (data.coverImage) formData.append('cover_image', data.coverImage)
if (data.profilePicture)
formData.append('profile_picture', data.profilePicture)
let avatarToken: string | undefined
if (data.profilePicture) {
const contentType = data.profilePicture.type || 'application/octet-stream'
const upload = await requestAssetUploadUrl({
filename: data.profilePicture.name,
contentType
})
const response = await api.fetchApi('/hub/profile', {
method: 'POST',
body: formData
await uploadFileToPresignedUrl({
uploadUrl: upload.uploadUrl,
file: data.profilePicture,
contentType
})
avatarToken = upload.token
}
const createdProfile = await createComfyHubProfile({
workspaceId: getCurrentWorkspaceId(),
username: data.username,
displayName: data.name,
description: data.description,
avatarToken
})
if (!response.ok) {
const body: unknown = await response.json().catch(() => ({}))
const message =
body && typeof body === 'object' && 'message' in body
? String((body as Record<string, unknown>).message)
: 'Failed to create profile'
throw new Error(message)
}
const createdProfile = mapHubProfileResponse(await response.json())
if (!createdProfile) {
throw new Error('Invalid profile response from server')
}
hasProfile.value = true
profile.value = createdProfile
return createdProfile

View File

@@ -0,0 +1,213 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
const mockGetShareableAssets = vi.hoisted(() => vi.fn())
const mockRequestAssetUploadUrl = vi.hoisted(() => vi.fn())
const mockUploadFileToPresignedUrl = vi.hoisted(() => vi.fn())
const mockPublishWorkflow = vi.hoisted(() => vi.fn())
const mockProfile = vi.hoisted(
() => ({ value: null }) as { value: ComfyHubProfile | null }
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
() => ({
useComfyHubProfileGate: () => ({
profile: mockProfile
})
})
)
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
useWorkflowShareService: () => ({
getShareableAssets: mockGetShareableAssets
})
}))
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
useComfyHubService: () => ({
requestAssetUploadUrl: mockRequestAssetUploadUrl,
uploadFileToPresignedUrl: mockUploadFileToPresignedUrl,
publishWorkflow: mockPublishWorkflow
})
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: {
path: 'workflows/demo-workflow.json'
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
const { useComfyHubPublishSubmission } =
await import('./useComfyHubPublishSubmission')
function createFormData(
overrides: Partial<ComfyHubPublishFormData> = {}
): ComfyHubPublishFormData {
return {
name: 'Demo workflow',
description: 'A demo workflow',
workflowType: 'imageGeneration',
tags: ['demo'],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: [],
...overrides
}
}
describe('useComfyHubPublishSubmission', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProfile.value = {
username: 'builder',
name: 'Builder'
}
mockGetShareableAssets.mockResolvedValue([
{ id: 'asset-1' },
{ id: 'asset-2' }
])
let uploadIndex = 0
mockRequestAssetUploadUrl.mockImplementation(
async ({ filename }: { filename: string }) => {
uploadIndex += 1
return {
uploadUrl: `https://upload.example.com/${filename}`,
publicUrl: `https://cdn.example.com/${filename}`,
token: `token-${uploadIndex}`
}
}
)
mockUploadFileToPresignedUrl.mockResolvedValue(undefined)
mockPublishWorkflow.mockResolvedValue({
share_id: 'share-1',
workflow_id: 'workflow-1'
})
})
it('maps imageComparison to image_comparison', async () => {
const beforeFile = new File(['before'], 'before.png', { type: 'image/png' })
const afterFile = new File(['after'], 'after.png', { type: 'image/png' })
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'imageComparison',
thumbnailFile: null,
comparisonBeforeFile: beforeFile,
comparisonAfterFile: afterFile
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailType: 'image_comparison'
})
)
})
it('uploads thumbnail and returns thumbnail token', async () => {
const thumbnailFile = new File(['thumbnail'], 'thumb.png', {
type: 'image/png'
})
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile
})
)
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
filename: 'thumb.png',
contentType: 'image/png'
})
expect(mockUploadFileToPresignedUrl).toHaveBeenCalledWith({
uploadUrl: 'https://upload.example.com/thumb.png',
file: thumbnailFile,
contentType: 'image/png'
})
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'token-1'
})
)
})
it('uploads selected sample images only', async () => {
const selectedFile = new File(['selected'], 'selected.png', {
type: 'image/png'
})
const unselectedFile = new File(['unselected'], 'unselected.png', {
type: 'image/png'
})
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile: null,
exampleImages: [
{
id: 'selected',
file: selectedFile,
url: 'blob:selected'
},
{
id: 'unselected',
file: unselectedFile,
url: 'blob:unselected'
}
],
selectedExampleIds: ['selected']
})
)
expect(mockRequestAssetUploadUrl).toHaveBeenCalledTimes(1)
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
filename: 'selected.png',
contentType: 'image/png'
})
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
sampleImageTokensOrUrls: ['token-1']
})
)
})
it('builds publish request with workflow filename + asset ids', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(createFormData())
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
username: 'builder',
workflowFilename: 'workflows/demo-workflow.json',
assetIds: ['asset-1', 'asset-2'],
name: 'Demo workflow',
description: 'A demo workflow',
tags: ['demo']
})
)
})
it('throws when profile username is unavailable', async () => {
mockProfile.value = null
const { submitToComfyHub } = useComfyHubPublishSubmission()
await expect(submitToComfyHub(createFormData())).rejects.toThrow(
'ComfyHub profile is required before publishing'
)
})
})

View File

@@ -0,0 +1,122 @@
import type { AssetInfo, ComfyHubProfile } from '@/schemas/apiSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import type {
ComfyHubApiThumbnailType,
ComfyHubPublishFormData,
ThumbnailType
} from '@/platform/workflow/sharing/types/comfyHubTypes'
function mapThumbnailType(type: ThumbnailType): ComfyHubApiThumbnailType {
if (type === 'imageComparison') {
return 'image_comparison'
}
return type
}
function getFileContentType(file: File): string {
return file.type || 'application/octet-stream'
}
function getSelectedExampleFiles(formData: ComfyHubPublishFormData): File[] {
const selectedImageIds = new Set(formData.selectedExampleIds)
return formData.exampleImages
.filter((image) => selectedImageIds.has(image.id))
.map((image) => image.file)
.filter((file): file is File => Boolean(file))
}
function getUsername(profile: ComfyHubProfile | null): string {
const username = profile?.username?.trim()
if (!username) {
throw new Error('ComfyHub profile is required before publishing')
}
return username
}
function getWorkflowFilename(path: string | null | undefined): string {
const workflowFilename = path?.trim()
if (!workflowFilename) {
throw new Error('No active workflow file available for publishing')
}
return workflowFilename
}
function getAssetIds(assets: AssetInfo[]): string[] {
return assets.map((asset) => asset.id)
}
export function useComfyHubPublishSubmission() {
const { profile } = useComfyHubProfileGate()
const { activeWorkflow } = useWorkflowStore()
const workflowShareService = useWorkflowShareService()
const comfyHubService = useComfyHubService()
async function uploadFileAndGetToken(file: File): Promise<string> {
const contentType = getFileContentType(file)
const upload = await comfyHubService.requestAssetUploadUrl({
filename: file.name,
contentType
})
await comfyHubService.uploadFileToPresignedUrl({
uploadUrl: upload.uploadUrl,
file,
contentType
})
return upload.token
}
async function submitToComfyHub(
formData: ComfyHubPublishFormData
): Promise<void> {
const username = getUsername(profile.value)
const workflowFilename = getWorkflowFilename(activeWorkflow?.path)
const assetIds = getAssetIds(
await workflowShareService.getShareableAssets()
)
const thumbnailType = mapThumbnailType(formData.thumbnailType)
const thumbnailTokenOrUrl =
formData.thumbnailFile && thumbnailType !== 'image_comparison'
? await uploadFileAndGetToken(formData.thumbnailFile)
: formData.comparisonBeforeFile
? await uploadFileAndGetToken(formData.comparisonBeforeFile)
: undefined
const thumbnailComparisonTokenOrUrl =
thumbnailType === 'image_comparison' && formData.comparisonAfterFile
? await uploadFileAndGetToken(formData.comparisonAfterFile)
: undefined
const selectedSampleFiles = getSelectedExampleFiles(formData)
const sampleImageTokensOrUrls =
selectedSampleFiles.length > 0
? await Promise.all(
selectedSampleFiles.map((file) => uploadFileAndGetToken(file))
)
: undefined
await comfyHubService.publishWorkflow({
username,
name: formData.name,
workflowFilename,
assetIds,
description: formData.description || undefined,
tags: formData.tags.length > 0 ? formData.tags : undefined,
thumbnailType,
thumbnailTokenOrUrl,
thumbnailComparisonTokenOrUrl,
sampleImageTokensOrUrls
})
}
return {
submitToComfyHub
}
}

View File

@@ -36,9 +36,28 @@ export const zHubProfileResponse = z.preprocess((data) => {
const d = data as Record<string, unknown>
return {
username: d.username,
name: d.name,
name: d.name ?? d.display_name,
description: d.description,
coverImageUrl: d.coverImageUrl ?? d.cover_image_url,
profilePictureUrl: d.profilePictureUrl ?? d.profile_picture_url
profilePictureUrl:
d.profilePictureUrl ?? d.profile_picture_url ?? d.avatar_url
}
}, zComfyHubProfile)
export const zHubAssetUploadUrlResponse = z
.object({
upload_url: z.string(),
public_url: z.string(),
token: z.string()
})
.transform((response) => ({
uploadUrl: response.upload_url,
publicUrl: response.public_url,
token: response.token
}))
export const zHubWorkflowPublishResponse = z.object({
share_id: z.string(),
workflow_id: z.string(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional()
})

View File

@@ -0,0 +1,198 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const mockFetchApi = vi.hoisted(() => vi.fn())
const mockGlobalFetch = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
}
}))
const { useComfyHubService } = await import('./comfyHubService')
function mockJsonResponse(payload: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: async () => payload
} as Response
}
function mockUploadResponse(ok = true, status = 200): Response {
return {
ok,
status,
json: async () => ({})
} as Response
}
describe('useComfyHubService', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.stubGlobal('fetch', mockGlobalFetch)
})
afterAll(() => {
vi.unstubAllGlobals()
})
it('requests upload url and returns token payload', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
upload_url: 'https://upload.example.com/object',
public_url: 'https://cdn.example.com/object',
token: 'upload-token'
})
)
const service = useComfyHubService()
const result = await service.requestAssetUploadUrl({
filename: 'thumb.png',
contentType: 'image/png'
})
expect(mockFetchApi).toHaveBeenCalledWith('/hub/assets/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: 'thumb.png',
content_type: 'image/png'
})
})
expect(result).toEqual({
uploadUrl: 'https://upload.example.com/object',
publicUrl: 'https://cdn.example.com/object',
token: 'upload-token'
})
})
it('uploads file to presigned url with PUT', async () => {
mockGlobalFetch.mockResolvedValue(mockUploadResponse())
const service = useComfyHubService()
const file = new File(['payload'], 'avatar.png', { type: 'image/png' })
await service.uploadFileToPresignedUrl({
uploadUrl: 'https://upload.example.com/object',
file,
contentType: 'image/png'
})
expect(mockGlobalFetch).toHaveBeenCalledWith(
'https://upload.example.com/object',
{
method: 'PUT',
headers: {
'Content-Type': 'image/png'
},
body: file
}
)
})
it('creates profile with workspace_id JSON body', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
id: 'profile-1',
username: 'builder',
display_name: 'Builder',
description: 'Builds workflows',
avatar_url: 'https://cdn.example.com/avatar.png',
website_urls: []
})
)
const service = useComfyHubService()
const profile = await service.createProfile({
workspaceId: 'workspace-1',
username: 'builder',
displayName: 'Builder',
description: 'Builds workflows',
avatarToken: 'avatar-token'
})
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspace_id: 'workspace-1',
username: 'builder',
display_name: 'Builder',
description: 'Builds workflows',
avatar_token: 'avatar-token'
})
})
expect(profile).toEqual({
username: 'builder',
name: 'Builder',
description: 'Builds workflows',
profilePictureUrl: 'https://cdn.example.com/avatar.png',
coverImageUrl: undefined
})
})
it('publishes workflow with mapped thumbnail enum', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
share_id: 'share-1',
workflow_id: 'workflow-1',
thumbnail_type: 'image_comparison'
})
)
const service = useComfyHubService()
await service.publishWorkflow({
username: 'builder',
name: 'My Flow',
workflowFilename: 'workflows/my-flow.json',
assetIds: ['asset-1'],
thumbnailType: 'imageComparison',
thumbnailTokenOrUrl: 'thumb-token',
thumbnailComparisonTokenOrUrl: 'thumb-compare-token',
sampleImageTokensOrUrls: ['sample-1']
})
const [, options] = mockFetchApi.mock.calls[0]
const body = JSON.parse(options.body as string)
expect(body).toMatchObject({
username: 'builder',
name: 'My Flow',
workflow_filename: 'workflows/my-flow.json',
asset_ids: ['asset-1'],
thumbnail_type: 'image_comparison',
thumbnail_token_or_url: 'thumb-token',
thumbnail_comparison_token_or_url: 'thumb-compare-token',
sample_image_tokens_or_urls: ['sample-1']
})
expect(mockFetchApi).toHaveBeenCalledWith('/hub/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
})
it('fetches current profile from /hub/profiles/me', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
id: 'profile-1',
username: 'builder',
display_name: 'Builder',
description: 'Builds workflows',
avatar_url: 'https://cdn.example.com/avatar.png',
website_urls: []
})
)
const service = useComfyHubService()
const profile = await service.getMyProfile()
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profiles/me')
expect(profile).toEqual({
username: 'builder',
name: 'Builder',
description: 'Builds workflows',
profilePictureUrl: 'https://cdn.example.com/avatar.png',
coverImageUrl: undefined
})
})
})

View File

@@ -0,0 +1,211 @@
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import {
zHubAssetUploadUrlResponse,
zHubProfileResponse,
zHubWorkflowPublishResponse
} from '@/platform/workflow/sharing/schemas/shareSchemas'
import { api } from '@/scripts/api'
type HubThumbnailType = 'image' | 'video' | 'image_comparison'
type ThumbnailTypeInput = HubThumbnailType | 'imageComparison'
interface CreateProfileInput {
workspaceId: string
username: string
displayName?: string
description?: string
avatarToken?: string
}
interface PublishWorkflowInput {
username: string
name: string
workflowFilename: string
assetIds: string[]
description?: string
tags?: string[]
thumbnailType?: ThumbnailTypeInput
thumbnailTokenOrUrl?: string
thumbnailComparisonTokenOrUrl?: string
sampleImageTokensOrUrls?: string[]
}
function normalizeThumbnailType(type: ThumbnailTypeInput): HubThumbnailType {
if (type === 'imageComparison') {
return 'image_comparison'
}
return type
}
async function parseErrorMessage(
response: Response,
fallbackMessage: string
): Promise<string> {
const body = await response.json().catch(() => null)
if (!body || typeof body !== 'object') {
return fallbackMessage
}
if ('message' in body && typeof body.message === 'string') {
return body.message
}
return fallbackMessage
}
async function parseRequiredJson<T>(
response: Response,
parser: {
safeParse: (
value: unknown
) => { success: true; data: T } | { success: false }
},
fallbackMessage: string
): Promise<T> {
const payload = await response.json().catch(() => null)
const parsed = parser.safeParse(payload)
if (!parsed.success) {
throw new Error(fallbackMessage)
}
return parsed.data
}
export function useComfyHubService() {
async function requestAssetUploadUrl(input: {
filename: string
contentType: string
}) {
const response = await api.fetchApi('/hub/assets/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: input.filename,
content_type: input.contentType
})
})
if (!response.ok) {
throw new Error(
await parseErrorMessage(response, 'Failed to request upload URL')
)
}
return parseRequiredJson(
response,
zHubAssetUploadUrlResponse,
'Invalid upload URL response from server'
)
}
async function uploadFileToPresignedUrl(input: {
uploadUrl: string
file: File
contentType: string
}): Promise<void> {
const response = await fetch(input.uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': input.contentType
},
body: input.file
})
if (!response.ok) {
throw new Error('Failed to upload file')
}
}
async function getMyProfile(): Promise<ComfyHubProfile | null> {
const response = await api.fetchApi('/hub/profiles/me')
if (!response.ok) {
if (response.status === 404) {
return null
}
throw new Error(
await parseErrorMessage(response, 'Failed to load ComfyHub profile')
)
}
return parseRequiredJson(
response,
zHubProfileResponse,
'Invalid profile response from server'
)
}
async function createProfile(
input: CreateProfileInput
): Promise<ComfyHubProfile> {
const response = await api.fetchApi('/hub/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspace_id: input.workspaceId,
username: input.username,
display_name: input.displayName,
description: input.description,
avatar_token: input.avatarToken
})
})
if (!response.ok) {
throw new Error(
await parseErrorMessage(response, 'Failed to create ComfyHub profile')
)
}
return parseRequiredJson(
response,
zHubProfileResponse,
'Invalid profile response from server'
)
}
async function publishWorkflow(input: PublishWorkflowInput) {
const body = {
username: input.username,
name: input.name,
workflow_filename: input.workflowFilename,
asset_ids: input.assetIds,
description: input.description,
tags: input.tags,
thumbnail_type: input.thumbnailType
? normalizeThumbnailType(input.thumbnailType)
: undefined,
thumbnail_token_or_url: input.thumbnailTokenOrUrl,
thumbnail_comparison_token_or_url: input.thumbnailComparisonTokenOrUrl,
sample_image_tokens_or_urls: input.sampleImageTokensOrUrls
}
const response = await api.fetchApi('/hub/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!response.ok) {
throw new Error(
await parseErrorMessage(response, 'Failed to publish workflow')
)
}
return parseRequiredJson(
response,
zHubWorkflowPublishResponse,
'Invalid publish response from server'
)
}
return {
requestAssetUploadUrl,
uploadFileToPresignedUrl,
getMyProfile,
createProfile,
publishWorkflow
}
}

View File

@@ -1,5 +1,7 @@
export type ThumbnailType = 'image' | 'video' | 'imageComparison'
export type ComfyHubApiThumbnailType = 'image' | 'video' | 'image_comparison'
export type ComfyHubWorkflowType =
| 'imageGeneration'
| 'videoGeneration'