mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
198
src/platform/workflow/sharing/services/comfyHubService.test.ts
Normal file
198
src/platform/workflow/sharing/services/comfyHubService.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
211
src/platform/workflow/sharing/services/comfyHubService.ts
Normal file
211
src/platform/workflow/sharing/services/comfyHubService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export type ThumbnailType = 'image' | 'video' | 'imageComparison'
|
||||
|
||||
export type ComfyHubApiThumbnailType = 'image' | 'video' | 'image_comparison'
|
||||
|
||||
export type ComfyHubWorkflowType =
|
||||
| 'imageGeneration'
|
||||
| 'videoGeneration'
|
||||
|
||||
Reference in New Issue
Block a user