Compare commits

...

6 Commits

Author SHA1 Message Date
Glary-Bot
797e4386f6 test(publish): loosen cache assertions to not require explicit undefined keys
Address CodeRabbit review feedback:

- cachePublishPrefill / getCachedPrefill roundtrip test: switch toEqual to
  toMatchObject so the assertion validates required fields without locking
  the cache contract to explicitly undefined keys.

- 'omits empty fields' test: assert each empty field is undefined directly,
  rather than asserting the whole shape, so future implementations can omit
  keys instead of setting them to undefined.
2026-05-03 07:04:19 +00:00
Glary-Bot
fc2a347ef2 test(publish): tighten test types and seed pre-existing metadata
Address CodeRabbit nitpick feedback:

1. useComfyHubPublishWizard.test.ts: seed formData.metadata with a non-empty
   value before calling applyPrefill({ metadata: {} }) so the test would catch
   an accidental no-op overwrite.

2. ComfyHubPublishWizardContent.test.ts: replace Record<string, unknown> casts
   with a Partial<ComfyHubPublishFormData>-typed lastFormUpdate() helper so
   prop-name typos fail typecheck.

3. ComfyHubThumbnailStep.test.ts: type renderStep with ComponentProps to catch
   incorrect prop/callback names at compile time.
2026-05-03 06:57:40 +00:00
bymyself
eb18c25ff7 test(publish): cover prefill cache, label refs, and thumbnail step handlers
Boost patch coverage for the republish prefill change:

- useComfyHubPublishWizard: cover cachePublishPrefill / getCachedPrefill
  roundtrip, blob/empty URL filtering, comparison-mode routing,
  metadata cloning, and additional applyPrefill respect-user-input
  branches.
- workflowShareService: cover labelRefsToDisplayNames whitespace
  display_name fallback, blank-ref filter, and empty arrays / empty
  metadata path that yields a null prefill.
- ComfyHubPublishWizardContent: wire stub emits for the new thumbnail
  handlers and verify the resulting onUpdateFormData payloads.
- ComfyHubThumbnailStep: new colocated test covering prefilled URL
  preview rendering (image, video extension detection, gif fallback,
  comparison previews) and clear emit in both image and comparison
  modes.
2026-05-02 23:51:14 -07:00
Glary-Bot
223bfb8f39 fix(publish): treat empty display_name as missing, clone metadata in prefill
Address CodeRabbit review feedback:

1. labelRefsToDisplayNames: trim display_name and fall back to name when it's
   empty or whitespace-only, instead of letting an empty string survive the
   coalesce and get filtered away later.

2. extractPrefillFromFormData / applyPrefill: shallow-clone metadata when it
   crosses the cache or form boundary so downstream mutations don't leak into
   shared state.
2026-05-03 06:08:21 +00:00
Glary-Bot
6101c7a1f7 fix(publish): use display labels in prefill, full cache symmetry, video URL detection
Address oracle review feedback:

1. Map hub label refs to display_name (with name fallback) instead of slug.
   The publish form holds display labels and normalizes to slugs only at
   submit time, so prefill now matches that contract.

2. Extend cachePublishPrefill / extractPrefillFromFormData to capture every
   PublishPrefill field. Previously the local cache fallback path would
   silently drop name, models, customNodes, tutorialUrl, metadata, and
   thumbnail URLs even though the server-prefill path now populates them.

3. Decide between <img> and <video> previews for prefilled thumbnails by
   inspecting the URL extension instead of the form mode. The video mode
   accepts GIF/WEBP, which were previously rendered through <video>.
2026-04-29 22:48:56 +00:00
Glary-Bot
e6d848a0e1 feat: prefill all fields when republishing a hub workflow
Republishing a workflow to ComfyHub previously left every form field blank
because the frontend mapped only 4 of the 19 fields the hub returns. Widen
the prefill type, schema, and applyPrefill() to cover name, description,
tags, models, custom nodes, thumbnail URL, comparison URLs, sample images,
tutorial URL, and metadata. Pass remote thumbnail and comparison URLs
through the submission flow without re-uploading when the user does not
choose a new file.

POST /hub/workflows is already an upsert (preserves share_id, replaces the
old version), so no PATCH endpoint or schema overhaul is needed.

Fixes BE-632
2026-04-29 22:38:18 +00:00
15 changed files with 1206 additions and 57 deletions

View File

@@ -102,6 +102,23 @@ export class PublishApiHelper {
})
}
async mockHubWorkflowDetail(
shareId: string,
detail: Record<string, unknown>
): Promise<void> {
await this.addRoute(`**/hub/workflows/${shareId}`, async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(detail)
})
})
}
async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> {
const response: ShareableAssetsResponse = { assets }
await this.addRoute('**/assets/from-workflow', async (route) => {

View File

@@ -0,0 +1,118 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper'
const PUBLISH_FEATURE_FLAGS = {
comfyhub_upload_enabled: true,
comfyhub_profile_gate_enabled: true
} as const
const PUBLISHED_SHARE_ID = 'prefill-share-id-1'
const PUBLISHED_WORKFLOW_NAME = 'Cinematic Upscale'
const PUBLISHED_DESCRIPTION = 'A polished cinematic upscale workflow.'
const PUBLISHED_THUMBNAIL_URL = 'https://cdn.example.com/thumb.png'
const PUBLISHED_SAMPLE_URL = 'https://cdn.example.com/sample-1.png'
const PUBLISHED_TUTORIAL_URL = 'https://www.youtube.com/watch?v=demo'
async function saveAndOpenPublishDialog(
comfyPage: ComfyPage,
dialog: PublishDialog,
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
try {
await overwriteDialog.waitFor({ state: 'visible', timeout: 500 })
await comfyPage.confirmDialog.click('overwrite')
} catch {
/* no-op when the workflow name is unique */
}
await dialog.open()
}
test.describe('Publish dialog - prefill on republish', () => {
test.beforeEach(async ({ comfyPage, publishApi }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({ hasProfile: true })
await publishApi.mockPublishStatus({
workflow_id: 'wf-prefill-1',
share_id: PUBLISHED_SHARE_ID,
publish_time: '2026-04-01T12:00:00Z',
listed: true,
assets: []
})
await publishApi.mockHubWorkflowDetail(PUBLISHED_SHARE_ID, {
name: PUBLISHED_WORKFLOW_NAME,
description: PUBLISHED_DESCRIPTION,
tags: [
{ name: 'anime', display_name: 'anime' },
{ name: 'upscale', display_name: 'upscale' }
],
models: [{ name: 'sdxl', display_name: 'SDXL' }],
custom_nodes: [],
thumbnail_type: 'image',
thumbnail_url: PUBLISHED_THUMBNAIL_URL,
sample_image_urls: [PUBLISHED_SAMPLE_URL],
tutorial_url: PUBLISHED_TUTORIAL_URL,
metadata: {}
})
})
test('prefills name and description from existing hub workflow', async ({
comfyPage,
publishDialog
}) => {
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-prefill-describe'
)
await expect(publishDialog.nameInput).toHaveValue(PUBLISHED_WORKFLOW_NAME)
await expect(publishDialog.descriptionTextarea).toHaveValue(
PUBLISHED_DESCRIPTION
)
})
test('prefills the thumbnail preview from existing hub workflow', async ({
comfyPage,
publishDialog
}) => {
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-prefill-thumb'
)
await publishDialog.goNext()
const thumbnailPreview =
publishDialog.root.getByAltText('Thumbnail preview')
await expect(thumbnailPreview).toBeVisible()
await expect(thumbnailPreview).toHaveAttribute(
'src',
PUBLISHED_THUMBNAIL_URL
)
})
test('prefills example images from existing hub workflow', async ({
comfyPage,
publishDialog
}) => {
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-prefill-examples'
)
await publishDialog.goNext()
const sampleImage = publishDialog.root.locator(
`img[src="${PUBLISHED_SAMPLE_URL}"]`
)
await expect(sampleImage).toBeVisible()
})
})

View File

@@ -52,8 +52,11 @@ function createDefaultFormData(): ComfyHubPublishFormData {
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
comparisonBeforeFile: null,
comparisonBeforeUrl: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {}
@@ -137,7 +140,24 @@ describe('ComfyHubPublishWizardContent', () => {
template: '<div data-testid="examples-step" />'
},
ComfyHubThumbnailStep: {
template: '<div data-testid="thumbnail-step" />'
template:
'<div data-testid="thumbnail-step">' +
'<button data-testid="emit-type" @click="$emit(\'update:thumbnailType\', \'video\')" />' +
'<button data-testid="emit-file" @click="$emit(\'update:thumbnailFile\', stubFile)" />' +
'<button data-testid="emit-before-file" @click="$emit(\'update:comparisonBeforeFile\', stubFile)" />' +
'<button data-testid="emit-after-file" @click="$emit(\'update:comparisonAfterFile\', stubFile)" />' +
'<button data-testid="emit-clear" @click="$emit(\'clear\')" />' +
'</div>',
emits: [
'update:thumbnailType',
'update:thumbnailFile',
'update:comparisonBeforeFile',
'update:comparisonAfterFile',
'clear'
],
setup() {
return { stubFile: new File(['x'], 'stub.png') }
}
},
ComfyHubProfilePromptPanel: {
template:
@@ -317,6 +337,93 @@ describe('ComfyHubPublishWizardContent', () => {
})
})
describe('thumbnail step handlers', () => {
function lastFormUpdate(): Partial<ComfyHubPublishFormData> {
const payload = onUpdateFormData.mock.calls.at(-1)?.[0]
expect(payload).toBeTruthy()
return payload as Partial<ComfyHubPublishFormData>
}
it('clears every file/url field when thumbnail type changes', async () => {
renderComponent({ currentStep: 'examples' })
await userEvent.click(screen.getByTestId('emit-type'))
expect(onUpdateFormData).toHaveBeenCalledWith({
thumbnailType: 'video',
thumbnailFile: null,
thumbnailUrl: null,
comparisonBeforeFile: null,
comparisonBeforeUrl: null,
comparisonAfterFile: null,
comparisonAfterUrl: null
})
})
it('clears thumbnailUrl when a new thumbnail file is chosen', async () => {
renderComponent({ currentStep: 'examples' })
await userEvent.click(screen.getByTestId('emit-file'))
const call = lastFormUpdate()
expect(call).toMatchObject({ thumbnailUrl: null })
expect(call.thumbnailFile).toBeInstanceOf(File)
})
it('clears comparisonBeforeUrl when a new comparison-before file is chosen', async () => {
renderComponent({ currentStep: 'examples' })
await userEvent.click(screen.getByTestId('emit-before-file'))
const call = lastFormUpdate()
expect(call).toMatchObject({ comparisonBeforeUrl: null })
expect(call.comparisonBeforeFile).toBeInstanceOf(File)
})
it('clears comparisonAfterUrl when a new comparison-after file is chosen', async () => {
renderComponent({ currentStep: 'examples' })
await userEvent.click(screen.getByTestId('emit-after-file'))
const call = lastFormUpdate()
expect(call).toMatchObject({ comparisonAfterUrl: null })
expect(call.comparisonAfterFile).toBeInstanceOf(File)
})
it('clears thumbnail file + url when child emits clear in image mode', async () => {
renderComponent({
currentStep: 'examples',
formData: { ...createDefaultFormData(), thumbnailType: 'image' }
})
await userEvent.click(screen.getByTestId('emit-clear'))
expect(onUpdateFormData).toHaveBeenCalledWith({
thumbnailFile: null,
thumbnailUrl: null
})
})
it('clears both comparison files + urls when child emits clear in comparison mode', async () => {
renderComponent({
currentStep: 'examples',
formData: {
...createDefaultFormData(),
thumbnailType: 'imageComparison'
}
})
await userEvent.click(screen.getByTestId('emit-clear'))
expect(onUpdateFormData).toHaveBeenCalledWith({
comparisonBeforeFile: null,
comparisonBeforeUrl: null,
comparisonAfterFile: null,
comparisonAfterUrl: null
})
})
})
describe('profileCreation step rendering', () => {
it('shows profile creation form when on profileCreation step', () => {
renderComponent({ currentStep: 'profileCreation' })

View File

@@ -24,14 +24,14 @@
>
<ComfyHubThumbnailStep
:thumbnail-type="formData.thumbnailType"
@update:thumbnail-type="onUpdateFormData({ thumbnailType: $event })"
@update:thumbnail-file="onUpdateFormData({ thumbnailFile: $event })"
@update:comparison-before-file="
onUpdateFormData({ comparisonBeforeFile: $event })
"
@update:comparison-after-file="
onUpdateFormData({ comparisonAfterFile: $event })
"
:thumbnail-url="formData.thumbnailUrl"
:comparison-before-url="formData.comparisonBeforeUrl"
:comparison-after-url="formData.comparisonAfterUrl"
@update:thumbnail-type="onUpdateThumbnailType"
@update:thumbnail-file="onUpdateThumbnailFile"
@update:comparison-before-file="onUpdateComparisonBeforeFile"
@update:comparison-after-file="onUpdateComparisonAfterFile"
@clear="onClearThumbnail"
/>
<ComfyHubExamplesStep
:example-images="formData.exampleImages"
@@ -140,6 +140,57 @@ const isPublishDisabled = computed(
(isFinishStepVisible.value && !finishStepReady.value)
)
function onUpdateThumbnailType(
value: ComfyHubPublishFormData['thumbnailType']
) {
onUpdateFormData({
thumbnailType: value,
thumbnailFile: null,
thumbnailUrl: null,
comparisonBeforeFile: null,
comparisonBeforeUrl: null,
comparisonAfterFile: null,
comparisonAfterUrl: null
})
}
function onUpdateThumbnailFile(value: File | null) {
onUpdateFormData({
thumbnailFile: value,
thumbnailUrl: null
})
}
function onUpdateComparisonBeforeFile(value: File | null) {
onUpdateFormData({
comparisonBeforeFile: value,
comparisonBeforeUrl: null
})
}
function onUpdateComparisonAfterFile(value: File | null) {
onUpdateFormData({
comparisonAfterFile: value,
comparisonAfterUrl: null
})
}
function onClearThumbnail() {
if (formData.thumbnailType === 'imageComparison') {
onUpdateFormData({
comparisonBeforeFile: null,
comparisonBeforeUrl: null,
comparisonAfterFile: null,
comparisonAfterUrl: null
})
return
}
onUpdateFormData({
thumbnailFile: null,
thumbnailUrl: null
})
}
async function handlePublish() {
if (isResolvingPublishAccess.value || isPublishing) {
return

View File

@@ -0,0 +1,158 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
type ThumbnailStepProps = ComponentProps<typeof ComfyHubThumbnailStep>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
vi.mock('@/platform/workflow/sharing/composables/useSliderFromMouse', () => ({
useSliderFromMouse: () => ({ value: 50 })
}))
vi.mock('@/platform/workflow/sharing/utils/validateFileSize', () => ({
isFileTooLarge: () => false,
MAX_IMAGE_SIZE_MB: 25,
MAX_VIDEO_SIZE_MB: 100
}))
function renderStep(
props: Partial<ThumbnailStepProps> = {},
callbacks: Partial<ThumbnailStepProps> = {}
) {
return render(ComfyHubThumbnailStep, {
props: { ...props, ...callbacks },
global: {
plugins: [i18n]
}
})
}
describe('ComfyHubThumbnailStep', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('prefilled URL preview', () => {
it('renders an <img> using thumbnailUrl when no file is selected', () => {
renderStep({ thumbnailUrl: 'https://cdn.example.com/thumb.png' })
const img = screen.getByAltText('comfyHubPublish.thumbnailPreview')
expect(img).toBeInTheDocument()
expect(img.getAttribute('src')).toBe('https://cdn.example.com/thumb.png')
expect(
screen.queryByLabelText('comfyHubPublish.videoPreview')
).not.toBeInTheDocument()
})
it('renders a <video> when thumbnailType is video and URL has a video extension', () => {
renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/clip.mp4?token=abc'
})
const video = screen.getByLabelText('comfyHubPublish.videoPreview')
expect(video.tagName.toLowerCase()).toBe('video')
expect(video.getAttribute('src')).toBe(
'https://cdn.example.com/clip.mp4?token=abc'
)
})
it('renders an <img> for video mode when URL points at an image (e.g. gif)', () => {
renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/anim.gif'
})
expect(
screen.getByAltText('comfyHubPublish.thumbnailPreview')
).toBeInTheDocument()
expect(
screen.queryByLabelText('comfyHubPublish.videoPreview')
).not.toBeInTheDocument()
})
it('renders both comparison previews when both URLs are provided', () => {
renderStep({
thumbnailType: 'imageComparison',
comparisonBeforeUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png'
})
const beforeImgs = screen.getAllByAltText(
'comfyHubPublish.uploadComparisonBeforePrompt'
)
const afterImgs = screen.getAllByAltText(
'comfyHubPublish.uploadComparisonAfterPrompt'
)
expect(
beforeImgs.some(
(el) =>
el.getAttribute('src') === 'https://cdn.example.com/before.png'
)
).toBe(true)
expect(
afterImgs.some(
(el) => el.getAttribute('src') === 'https://cdn.example.com/after.png'
)
).toBe(true)
})
it('shows upload prompt when no thumbnail file or URL is set', () => {
renderStep()
expect(
screen.queryByAltText('comfyHubPublish.thumbnailPreview')
).not.toBeInTheDocument()
expect(
screen.getByText('comfyHubPublish.uploadPromptClickToBrowse')
).toBeInTheDocument()
})
})
describe('clear emits', () => {
it('emits clear (image mode) when the user clicks Clear with a prefilled URL', async () => {
const onClear = vi.fn()
renderStep(
{ thumbnailUrl: 'https://cdn.example.com/thumb.png' },
{ onClear }
)
await userEvent.click(screen.getByRole('button', { name: 'g.clear' }))
expect(onClear).toHaveBeenCalledOnce()
})
it('emits clear (comparison mode) when at least one comparison URL is set', async () => {
const onClear = vi.fn()
renderStep(
{
thumbnailType: 'imageComparison',
comparisonBeforeUrl: 'https://cdn.example.com/before.png'
},
{ onClear }
)
await userEvent.click(screen.getByRole('button', { name: 'g.clear' }))
expect(onClear).toHaveBeenCalledOnce()
})
it('hides Clear when there is no thumbnail content', () => {
renderStep()
expect(
screen.queryByRole('button', { name: 'g.clear' })
).not.toBeInTheDocument()
})
})
})

View File

@@ -197,8 +197,16 @@ import { useDropZone, useObjectUrl } from '@vueuse/core'
import { computed, reactive, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
const { thumbnailType = 'image' } = defineProps<{
const {
thumbnailType = 'image',
thumbnailUrl = null,
comparisonBeforeUrl = null,
comparisonAfterUrl = null
} = defineProps<{
thumbnailType?: ThumbnailType
thumbnailUrl?: string | null
comparisonBeforeUrl?: string | null
comparisonAfterUrl?: string | null
}>()
const emit = defineEmits<{
@@ -206,6 +214,7 @@ const emit = defineEmits<{
'update:thumbnailFile': [value: File | null]
'update:comparisonBeforeFile': [value: File | null]
'update:comparisonAfterFile': [value: File | null]
clear: []
}>()
const { t } = useI18n()
@@ -216,11 +225,9 @@ function isThumbnailType(value: string): value is ThumbnailType {
function handleThumbnailTypeChange(value: unknown) {
if (typeof value === 'string' && isThumbnailType(value)) {
thumbnailFile.value = null
comparisonBeforeFile.value = null
comparisonAfterFile.value = null
emit('update:thumbnailFile', null)
emit('update:comparisonBeforeFile', null)
emit('update:comparisonAfterFile', null)
emit('update:thumbnailType', value)
}
}
@@ -258,8 +265,23 @@ const thumbnailOptions = [
]
const thumbnailFile = shallowRef<File | null>(null)
const thumbnailPreviewUrl = useObjectUrl(thumbnailFile)
const isVideoFile = ref(false)
const thumbnailObjectUrl = useObjectUrl(thumbnailFile)
const thumbnailPreviewUrl = computed(
() => thumbnailObjectUrl.value ?? thumbnailUrl ?? undefined
)
function urlLooksLikeVideo(url: string | null | undefined): boolean {
if (!url) return false
const path = url.split(/[?#]/, 1)[0] ?? ''
return /\.(mp4|webm|mov|m4v|ogv|ogg)$/i.test(path)
}
const isVideoFile = computed(() => {
if (thumbnailFile.value) {
return thumbnailFile.value.type.startsWith('video/')
}
if (thumbnailType !== 'video') return false
return urlLooksLikeVideo(thumbnailUrl)
})
function setThumbnailPreview(file: File) {
const maxSize = file.type.startsWith('video/')
@@ -267,15 +289,20 @@ function setThumbnailPreview(file: File) {
: MAX_IMAGE_SIZE_MB
if (isFileTooLarge(file, maxSize)) return
thumbnailFile.value = file
isVideoFile.value = file.type.startsWith('video/')
emit('update:thumbnailFile', file)
}
const comparisonBeforeFile = shallowRef<File | null>(null)
const comparisonAfterFile = shallowRef<File | null>(null)
const comparisonBeforeObjectUrl = useObjectUrl(comparisonBeforeFile)
const comparisonAfterObjectUrl = useObjectUrl(comparisonAfterFile)
const comparisonPreviewUrls = reactive({
before: useObjectUrl(comparisonBeforeFile),
after: useObjectUrl(comparisonAfterFile)
before: computed(
() => comparisonBeforeObjectUrl.value ?? comparisonBeforeUrl ?? undefined
),
after: computed(
() => comparisonAfterObjectUrl.value ?? comparisonAfterUrl ?? undefined
)
})
const hasBothComparisonImages = computed(
@@ -296,13 +323,10 @@ function clearAllPreviews() {
if (thumbnailType === 'imageComparison') {
comparisonBeforeFile.value = null
comparisonAfterFile.value = null
emit('update:comparisonBeforeFile', null)
emit('update:comparisonAfterFile', null)
return
} else {
thumbnailFile.value = null
}
thumbnailFile.value = null
emit('update:thumbnailFile', null)
emit('clear')
}
function handleFileSelect(event: Event) {

View File

@@ -58,8 +58,11 @@ function createFormData(
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
comparisonBeforeFile: null,
comparisonBeforeUrl: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {},
@@ -187,6 +190,66 @@ describe('useComfyHubPublishSubmission', () => {
)
})
it('passes prefilled thumbnail URL through without re-uploading', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: 'https://cdn.example.com/existing-thumb.png'
})
)
expect(mockRequestAssetUploadUrl).not.toHaveBeenCalled()
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'https://cdn.example.com/existing-thumb.png'
})
)
})
it('uploads a newly chosen thumbnail file even when a prefill URL is also present', async () => {
const file = new File(['new'], 'new-thumb.png', { type: 'image/png' })
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile: file,
thumbnailUrl: 'https://cdn.example.com/old.png'
})
)
expect(mockRequestAssetUploadUrl).toHaveBeenCalledTimes(1)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'token-1'
})
)
})
it('passes prefilled comparison URLs through for imageComparison thumbnails', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'imageComparison',
comparisonBeforeFile: null,
comparisonBeforeUrl: 'https://cdn.example.com/before.png',
comparisonAfterFile: null,
comparisonAfterUrl: 'https://cdn.example.com/after.png'
})
)
expect(mockRequestAssetUploadUrl).not.toHaveBeenCalled()
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailType: 'imageComparison',
thumbnailTokenOrUrl: 'https://cdn.example.com/before.png',
thumbnailComparisonTokenOrUrl: 'https://cdn.example.com/after.png'
})
)
})
it('throws when profile username is unavailable', async () => {
mockProfile.value = null

View File

@@ -32,13 +32,24 @@ function getAssetIds(assets: AssetInfo[]): string[] {
return assets.map((asset) => asset.id)
}
function resolveThumbnailFile(
interface ThumbnailSource {
file: File | null
url: string | null
}
function resolveThumbnailSource(
formData: ComfyHubPublishFormData
): File | undefined {
): ThumbnailSource {
if (formData.thumbnailType === 'imageComparison') {
return formData.comparisonBeforeFile ?? undefined
return {
file: formData.comparisonBeforeFile,
url: formData.comparisonBeforeUrl
}
}
return {
file: formData.thumbnailFile,
url: formData.thumbnailUrl
}
return formData.thumbnailFile ?? undefined
}
export function useComfyHubPublishSubmission() {
@@ -74,14 +85,15 @@ export function useComfyHubPublishSubmission() {
await workflowShareService.getShareableAssets()
)
const thumbnailFile = resolveThumbnailFile(formData)
const thumbnailTokenOrUrl = thumbnailFile
? await uploadFileAndGetToken(thumbnailFile)
: undefined
const thumbnail = resolveThumbnailSource(formData)
const thumbnailTokenOrUrl = thumbnail.file
? await uploadFileAndGetToken(thumbnail.file)
: (thumbnail.url ?? undefined)
const thumbnailComparisonTokenOrUrl =
formData.thumbnailType === 'imageComparison' &&
formData.comparisonAfterFile
? await uploadFileAndGetToken(formData.comparisonAfterFile)
formData.thumbnailType === 'imageComparison'
? formData.comparisonAfterFile
? await uploadFileAndGetToken(formData.comparisonAfterFile)
: (formData.comparisonAfterUrl ?? undefined)
: undefined
const sampleImageTokensOrUrls =

View File

@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
const mockActiveWorkflow = vi.hoisted(() => ({
value: { filename: 'my-workflow.json' } as { filename: string } | null
}))
@@ -12,7 +14,29 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
})
}))
const { useComfyHubPublishWizard } = await import('./useComfyHubPublishWizard')
const { useComfyHubPublishWizard, cachePublishPrefill, getCachedPrefill } =
await import('./useComfyHubPublishWizard')
function makeFormData(overrides: Partial<ComfyHubPublishFormData> = {}) {
return {
name: '',
description: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image' as const,
thumbnailFile: null,
thumbnailUrl: null,
comparisonBeforeFile: null,
comparisonBeforeUrl: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {},
...overrides
} satisfies ComfyHubPublishFormData
}
describe('useComfyHubPublishWizard', () => {
beforeEach(() => {
@@ -115,6 +139,268 @@ describe('useComfyHubPublishWizard', () => {
})
})
describe('applyPrefill', () => {
it('populates every form field from the prefill payload', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
mockActiveWorkflow.value = { filename: 'my-workflow.json' }
applyPrefill({
name: 'Cinematic Upscale',
description: 'A polished workflow',
tags: ['art', 'upscale'],
models: ['sdxl'],
customNodes: ['impact-pack'],
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
sampleImageUrls: ['https://cdn.example.com/sample-1.png'],
tutorialUrl: 'https://youtube.com/watch?v=abc',
metadata: { extended_description: 'long form' }
})
expect(formData.value).toMatchObject({
name: 'Cinematic Upscale',
description: 'A polished workflow',
tags: ['art', 'upscale'],
models: ['sdxl'],
customNodes: ['impact-pack'],
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
comparisonBeforeUrl: null,
comparisonAfterUrl: null,
tutorialUrl: 'https://youtube.com/watch?v=abc',
metadata: { extended_description: 'long form' }
})
expect(formData.value.exampleImages).toHaveLength(1)
expect(formData.value.exampleImages[0]?.url).toBe(
'https://cdn.example.com/sample-1.png'
)
})
it('routes thumbnail URLs into comparison slots when type is imageComparison', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
applyPrefill({
thumbnailType: 'imageComparison',
thumbnailUrl: 'https://cdn.example.com/before.png',
thumbnailComparisonUrl: 'https://cdn.example.com/after.png'
})
expect(formData.value.thumbnailType).toBe('imageComparison')
expect(formData.value.thumbnailUrl).toBeNull()
expect(formData.value.comparisonBeforeUrl).toBe(
'https://cdn.example.com/before.png'
)
expect(formData.value.comparisonAfterUrl).toBe(
'https://cdn.example.com/after.png'
)
})
it('respects user input over prefill for fields the user has edited', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
formData.value.description = 'User-typed description'
formData.value.tags = ['user-tag']
formData.value.tutorialUrl = 'https://user.example.com'
applyPrefill({
description: 'Prefill description',
tags: ['prefill-tag'],
tutorialUrl: 'https://prefill.example.com'
})
expect(formData.value.description).toBe('User-typed description')
expect(formData.value.tags).toEqual(['user-tag'])
expect(formData.value.tutorialUrl).toBe('https://user.example.com')
})
it('does not overwrite an already chosen thumbnail file with a prefill URL', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
const userFile = new File(['x'], 'user.png', { type: 'image/png' })
formData.value.thumbnailFile = userFile
applyPrefill({
thumbnailUrl: 'https://cdn.example.com/prefilled.png'
})
expect(formData.value.thumbnailFile?.name).toBe('user.png')
expect(formData.value.thumbnailUrl).toBeNull()
})
it('preserves user-chosen models, customNodes, exampleImages, and metadata over prefill', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
formData.value.models = ['user-model']
formData.value.customNodes = ['user-node']
formData.value.exampleImages = [
{ id: 'user-1', url: 'https://user.example.com/a.png' }
]
formData.value.metadata = { user_key: 'user_value' }
applyPrefill({
models: ['prefill-model'],
customNodes: ['prefill-node'],
sampleImageUrls: ['https://prefill.example.com/b.png'],
metadata: { prefill_key: 'prefill_value' }
})
expect(formData.value.models).toEqual(['user-model'])
expect(formData.value.customNodes).toEqual(['user-node'])
expect(formData.value.exampleImages).toEqual([
{ id: 'user-1', url: 'https://user.example.com/a.png' }
])
expect(formData.value.metadata).toEqual({ user_key: 'user_value' })
})
it('treats an empty prefill metadata object as no prefill', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
formData.value.metadata = { user_key: 'keep-me' }
applyPrefill({ metadata: {} })
expect(formData.value.metadata).toEqual({ user_key: 'keep-me' })
})
it('does not overwrite a prefilled thumbnailUrl on a second applyPrefill', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
applyPrefill({ thumbnailUrl: 'https://cdn.example.com/first.png' })
applyPrefill({ thumbnailUrl: 'https://cdn.example.com/second.png' })
expect(formData.value.thumbnailUrl).toBe(
'https://cdn.example.com/first.png'
)
})
it('does not overwrite a chosen comparison-before file when applying comparison prefill', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
const beforeFile = new File(['b'], 'before.png', { type: 'image/png' })
formData.value.thumbnailType = 'imageComparison'
formData.value.comparisonBeforeFile = beforeFile
applyPrefill({
thumbnailType: 'imageComparison',
thumbnailUrl: 'https://cdn.example.com/before-prefill.png',
thumbnailComparisonUrl: 'https://cdn.example.com/after-prefill.png'
})
expect(formData.value.comparisonBeforeFile?.name).toBe('before.png')
expect(formData.value.comparisonBeforeUrl).toBeNull()
expect(formData.value.comparisonAfterUrl).toBe(
'https://cdn.example.com/after-prefill.png'
)
})
})
describe('cachePublishPrefill / getCachedPrefill', () => {
it('returns null when no prefill is cached for the workflow path', () => {
expect(getCachedPrefill('workflows/never-cached.json')).toBeNull()
})
it('roundtrips every cacheable form field through extractPrefillFromFormData', () => {
const path = 'workflows/cached-full.json'
cachePublishPrefill(
path,
makeFormData({
name: 'My Workflow',
description: 'Cached description',
tags: ['art'],
models: ['sdxl'],
customNodes: ['impact-pack'],
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
exampleImages: [
{ id: 'a', url: 'https://cdn.example.com/sample.png' },
{ id: 'b', url: 'blob:https://localhost/abc' },
{ id: 'c', url: '' }
],
tutorialUrl: 'https://youtube.com/abc',
metadata: { extra: 'value' }
})
)
expect(getCachedPrefill(path)).toMatchObject({
name: 'My Workflow',
description: 'Cached description',
tags: ['art'],
models: ['sdxl'],
customNodes: ['impact-pack'],
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
sampleImageUrls: ['https://cdn.example.com/sample.png'],
tutorialUrl: 'https://youtube.com/abc',
metadata: { extra: 'value' }
})
})
it('routes comparison-before URL into thumbnailUrl when type is imageComparison', () => {
const path = 'workflows/cached-comparison.json'
cachePublishPrefill(
path,
makeFormData({
thumbnailType: 'imageComparison',
comparisonBeforeUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png'
})
)
const cached = getCachedPrefill(path)
expect(cached?.thumbnailUrl).toBe('https://cdn.example.com/before.png')
expect(cached?.thumbnailComparisonUrl).toBe(
'https://cdn.example.com/after.png'
)
})
it('omits sampleImageUrls when every example URL is blob: or empty', () => {
const path = 'workflows/cached-blobs.json'
cachePublishPrefill(
path,
makeFormData({
exampleImages: [
{ id: 'a', url: 'blob:https://localhost/x' },
{ id: 'b', url: '' }
]
})
)
expect(getCachedPrefill(path)?.sampleImageUrls).toBeUndefined()
})
it('omits empty arrays/strings/metadata fields', () => {
const path = 'workflows/cached-minimal.json'
cachePublishPrefill(path, makeFormData({ thumbnailType: 'image' }))
const cached = getCachedPrefill(path)
expect(cached?.thumbnailType).toBe('image')
expect(cached?.name).toBeUndefined()
expect(cached?.description).toBeUndefined()
expect(cached?.tags).toBeUndefined()
expect(cached?.models).toBeUndefined()
expect(cached?.customNodes).toBeUndefined()
expect(cached?.thumbnailUrl).toBeUndefined()
expect(cached?.thumbnailComparisonUrl).toBeUndefined()
expect(cached?.sampleImageUrls).toBeUndefined()
expect(cached?.tutorialUrl).toBeUndefined()
expect(cached?.metadata).toBeUndefined()
})
it('clones tags/models/metadata so later form mutations do not leak into the cache', () => {
const tags = ['t1']
const models = ['m1']
const metadata = { k: 'v' }
const path = 'workflows/cache-cloning.json'
cachePublishPrefill(path, makeFormData({ tags, models, metadata }))
tags.push('mutated')
models.push('mutated')
metadata.k = 'mutated'
expect(getCachedPrefill(path)).toMatchObject({
tags: ['t1'],
models: ['m1'],
metadata: { k: 'v' }
})
})
})
describe('profile creation step', () => {
it('navigates to profileCreation step', () => {
const { currentStep, openProfileCreationStep } =

View File

@@ -8,7 +8,6 @@ import type {
} from '@/platform/workflow/sharing/types/comfyHubTypes'
import type { PublishPrefill } from '@/platform/workflow/sharing/types/shareTypes'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { normalizeTags } from '@/platform/workflow/sharing/utils/normalizeTags'
const PUBLISH_STEPS = [
'describe',
@@ -32,8 +31,11 @@ function createDefaultFormData(): ComfyHubPublishFormData {
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
comparisonBeforeFile: null,
comparisonBeforeUrl: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {}
@@ -44,16 +46,40 @@ function createExampleImagesFromUrls(urls: string[]): ExampleImage[] {
return urls.map((url) => ({ id: uuidv4(), url }))
}
function nonBlobUrlsFromExampleImages(
exampleImages: ComfyHubPublishFormData['exampleImages']
): string[] | undefined {
const urls = exampleImages
.map((img) => img.url)
.filter((url) => url.length > 0 && !url.startsWith('blob:'))
return urls.length > 0 ? urls : undefined
}
function extractPrefillFromFormData(
formData: ComfyHubPublishFormData
): PublishPrefill {
return {
name: formData.name || undefined,
description: formData.description || undefined,
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
tags: formData.tags.length > 0 ? [...formData.tags] : undefined,
models: formData.models.length > 0 ? [...formData.models] : undefined,
customNodes:
formData.customNodes.length > 0 ? [...formData.customNodes] : undefined,
thumbnailType: formData.thumbnailType,
sampleImageUrls: formData.exampleImages
.map((img) => img.url)
.filter((url) => !url.startsWith('blob:'))
thumbnailUrl:
formData.thumbnailType !== 'imageComparison'
? (formData.thumbnailUrl ?? undefined)
: (formData.comparisonBeforeUrl ?? undefined),
thumbnailComparisonUrl:
formData.thumbnailType === 'imageComparison'
? (formData.comparisonAfterUrl ?? undefined)
: undefined,
sampleImageUrls: nonBlobUrlsFromExampleImages(formData.exampleImages),
tutorialUrl: formData.tutorialUrl || undefined,
metadata:
Object.keys(formData.metadata).length > 0
? { ...formData.metadata }
: undefined
}
}
@@ -95,8 +121,18 @@ export function useComfyHubPublishWizard() {
function applyPrefill(prefill: PublishPrefill) {
const defaults = createDefaultFormData()
const current = formData.value
const resolvedThumbnailType =
current.thumbnailType === defaults.thumbnailType
? (prefill.thumbnailType ?? current.thumbnailType)
: current.thumbnailType
const isComparison = resolvedThumbnailType === 'imageComparison'
formData.value = {
...current,
name:
current.name === defaults.name
? (prefill.name ?? current.name)
: current.name,
description:
current.description === defaults.description
? (prefill.description ?? current.description)
@@ -105,14 +141,47 @@ export function useComfyHubPublishWizard() {
current.tags.length === 0 && prefill.tags?.length
? prefill.tags
: current.tags,
thumbnailType:
current.thumbnailType === defaults.thumbnailType
? (prefill.thumbnailType ?? current.thumbnailType)
: current.thumbnailType,
models:
current.models.length === 0 && prefill.models?.length
? prefill.models
: current.models,
customNodes:
current.customNodes.length === 0 && prefill.customNodes?.length
? prefill.customNodes
: current.customNodes,
thumbnailType: resolvedThumbnailType,
thumbnailUrl:
!isComparison &&
current.thumbnailFile === null &&
current.thumbnailUrl === null
? (prefill.thumbnailUrl ?? null)
: current.thumbnailUrl,
comparisonBeforeUrl:
isComparison &&
current.comparisonBeforeFile === null &&
current.comparisonBeforeUrl === null
? (prefill.thumbnailUrl ?? null)
: current.comparisonBeforeUrl,
comparisonAfterUrl:
isComparison &&
current.comparisonAfterFile === null &&
current.comparisonAfterUrl === null
? (prefill.thumbnailComparisonUrl ?? null)
: current.comparisonAfterUrl,
exampleImages:
current.exampleImages.length === 0 && prefill.sampleImageUrls?.length
? createExampleImagesFromUrls(prefill.sampleImageUrls)
: current.exampleImages
: current.exampleImages,
tutorialUrl:
current.tutorialUrl === defaults.tutorialUrl
? (prefill.tutorialUrl ?? current.tutorialUrl)
: current.tutorialUrl,
metadata:
Object.keys(current.metadata).length === 0 &&
prefill.metadata &&
Object.keys(prefill.metadata).length > 0
? { ...prefill.metadata }
: current.metadata
}
}

View File

@@ -10,13 +10,23 @@ export const zPublishRecordResponse = z.object({
assets: z.array(zAssetInfo).optional()
})
const zHubLabelRefOrString = z.union([
z.string(),
z.object({ name: z.string(), display_name: z.string().optional() })
])
export const zHubWorkflowPrefillResponse = z.object({
name: z.string().nullish(),
description: z.string().nullish(),
tags: z.array(z.string()).nullish(),
tags: z.array(zHubLabelRefOrString).nullish(),
models: z.array(zHubLabelRefOrString).nullish(),
custom_nodes: z.array(zHubLabelRefOrString).nullish(),
sample_image_urls: z.array(z.string()).nullish(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).nullish(),
thumbnail_url: z.string().nullish(),
thumbnail_comparison_url: z.string().nullish()
thumbnail_comparison_url: z.string().nullish(),
tutorial_url: z.string().nullish(),
metadata: z.record(z.string(), z.unknown()).nullish()
})
/**

View File

@@ -199,6 +199,177 @@ describe(useWorkflowShareService, () => {
expect(mockFetchApi).toHaveBeenNthCalledWith(2, '/hub/workflows/wf-prefill')
})
it('maps every prefill field from a full hub workflow detail response', async () => {
mockFetchApi.mockImplementation(async (path: string) => {
if (path === '/userdata/wf-full/publish') {
return mockJsonResponse({
workflow_id: 'wf-full',
share_id: 'wf-full',
publish_time: '2026-02-23T00:00:00Z',
listed: true
})
}
if (path === '/hub/workflows/wf-full') {
return mockJsonResponse({
name: 'Full Workflow',
description: 'Everything filled in',
tags: [
{ name: 'text-to-image', display_name: 'Text to Image' },
{ name: 'upscale', display_name: 'Upscale' }
],
models: [{ name: 'sdxl', display_name: 'SDXL' }],
custom_nodes: [
{ name: 'comfyui-impact-pack', display_name: 'Impact Pack' }
],
thumbnail_type: 'image',
thumbnail_url: 'https://cdn.example.com/thumb.png',
thumbnail_comparison_url: 'https://cdn.example.com/after.png',
sample_image_urls: ['https://cdn.example.com/sample-1.png'],
tutorial_url: 'https://youtube.com/watch?v=abc',
metadata: { extended_description: 'long form text' }
})
}
return mockJsonResponse({}, false, 404)
})
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-full')
expect(status.isPublished).toBe(true)
expect(status.prefill).toEqual({
name: 'Full Workflow',
description: 'Everything filled in',
tags: ['Text to Image', 'Upscale'],
models: ['SDXL'],
customNodes: ['Impact Pack'],
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
thumbnailComparisonUrl: 'https://cdn.example.com/after.png',
sampleImageUrls: ['https://cdn.example.com/sample-1.png'],
tutorialUrl: 'https://youtube.com/watch?v=abc',
metadata: { extended_description: 'long form text' }
})
})
it('falls back to label slug when display_name is missing', async () => {
mockFetchApi.mockImplementation(async (path: string) => {
if (path === '/userdata/wf-fallback/publish') {
return mockJsonResponse({
workflow_id: 'wf-fallback',
share_id: 'wf-fallback',
publish_time: '2026-02-23T00:00:00Z',
listed: true
})
}
if (path === '/hub/workflows/wf-fallback') {
return mockJsonResponse({
tags: [{ name: 'realism' }, { name: 'portrait' }]
})
}
return mockJsonResponse({}, false, 404)
})
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-fallback')
expect(status.prefill).toEqual({ tags: ['realism', 'portrait'] })
})
it('accepts string-shaped tag arrays for backward compatibility', async () => {
mockFetchApi.mockImplementation(async (path: string) => {
if (path === '/userdata/wf-strings/publish') {
return mockJsonResponse({
workflow_id: 'wf-strings',
share_id: 'wf-strings',
publish_time: '2026-02-23T00:00:00Z',
listed: true
})
}
if (path === '/hub/workflows/wf-strings') {
return mockJsonResponse({
tags: ['portrait', 'realism'],
models: ['flux'],
custom_nodes: []
})
}
return mockJsonResponse({}, false, 404)
})
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-strings')
expect(status.prefill).toEqual({
tags: ['portrait', 'realism'],
models: ['flux']
})
})
it('drops empty arrays and empty metadata so prefill is null when hub details are blank', async () => {
mockFetchApi.mockImplementation(async (path: string) => {
if (path === '/userdata/wf-blank/publish') {
return mockJsonResponse({
workflow_id: 'wf-blank',
share_id: 'wf-blank',
publish_time: '2026-02-23T00:00:00Z',
listed: true
})
}
if (path === '/hub/workflows/wf-blank') {
return mockJsonResponse({
tags: [],
models: [],
custom_nodes: [],
metadata: {}
})
}
return mockJsonResponse({}, false, 404)
})
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-blank')
expect(status.prefill).toBeNull()
})
it('falls back to label name when display_name is whitespace-only and drops blank refs', async () => {
mockFetchApi.mockImplementation(async (path: string) => {
if (path === '/userdata/wf-ws/publish') {
return mockJsonResponse({
workflow_id: 'wf-ws',
share_id: 'wf-ws',
publish_time: '2026-02-23T00:00:00Z',
listed: true
})
}
if (path === '/hub/workflows/wf-ws') {
return mockJsonResponse({
tags: [
{ name: 'realism', display_name: ' ' },
{ name: ' ', display_name: ' ' },
{ name: 'portrait', display_name: 'Portrait' }
],
models: [{ name: '', display_name: '' }]
})
}
return mockJsonResponse({}, false, 404)
})
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-ws')
expect(status.prefill).toEqual({ tags: ['realism', 'Portrait'] })
})
it('returns null prefill when hub workflow details are unavailable', async () => {
mockFetchApi.mockImplementation(async (path: string) => {
if (path === '/userdata/wf-no-meta/publish') {

View File

@@ -39,29 +39,82 @@ function mapApiThumbnailType(
return value
}
type LabelRefOrString = string | { name: string; display_name?: string }
interface PrefillMetadataFields {
name?: string | null
description?: string | null
tags?: string[] | null
tags?: LabelRefOrString[] | null
models?: LabelRefOrString[] | null
custom_nodes?: LabelRefOrString[] | null
thumbnail_type?: 'image' | 'video' | 'image_comparison' | null
thumbnail_url?: string | null
thumbnail_comparison_url?: string | null
sample_image_urls?: string[] | null
tutorial_url?: string | null
metadata?: Record<string, unknown> | null
}
function labelRefsToDisplayNames(
refs: LabelRefOrString[] | null | undefined
): string[] | undefined {
if (!refs?.length) return undefined
const labels = refs
.map((ref) => {
if (typeof ref === 'string') return ref
const display = ref.display_name?.trim()
return display && display.length > 0 ? display : ref.name
})
.filter((label) => label.trim().length > 0)
return labels.length > 0 ? labels : undefined
}
function extractPrefill(fields: PrefillMetadataFields): PublishPrefill | null {
const name = fields.name ?? undefined
const description = fields.description ?? undefined
const tags = fields.tags ?? undefined
const tags = labelRefsToDisplayNames(fields.tags)
const models = labelRefsToDisplayNames(fields.models)
const customNodes = labelRefsToDisplayNames(fields.custom_nodes)
const thumbnailType = mapApiThumbnailType(fields.thumbnail_type)
const thumbnailUrl = fields.thumbnail_url ?? undefined
const thumbnailComparisonUrl = fields.thumbnail_comparison_url ?? undefined
const sampleImageUrls = fields.sample_image_urls ?? undefined
const tutorialUrl = fields.tutorial_url ?? undefined
const metadata =
fields.metadata && Object.keys(fields.metadata).length > 0
? fields.metadata
: undefined
if (
!description &&
!tags?.length &&
!thumbnailType &&
!sampleImageUrls?.length
) {
const hasAnything =
name ||
description ||
tags?.length ||
models?.length ||
customNodes?.length ||
thumbnailType ||
thumbnailUrl ||
thumbnailComparisonUrl ||
sampleImageUrls?.length ||
tutorialUrl ||
metadata
if (!hasAnything) {
return null
}
return { description, tags, thumbnailType, sampleImageUrls }
return {
name,
description,
tags,
models,
customNodes,
thumbnailType,
thumbnailUrl,
thumbnailComparisonUrl,
sampleImageUrls,
tutorialUrl,
metadata
}
}
function decodeHubWorkflowPrefill(payload: unknown): PublishPrefill | null {

View File

@@ -14,8 +14,11 @@ export interface ComfyHubPublishFormData {
customNodes: string[]
thumbnailType: ThumbnailType
thumbnailFile: File | null
thumbnailUrl: string | null
comparisonBeforeFile: File | null
comparisonBeforeUrl: string | null
comparisonAfterFile: File | null
comparisonAfterUrl: string | null
exampleImages: ExampleImage[]
tutorialUrl: string
metadata: Record<string, unknown>

View File

@@ -12,10 +12,17 @@ export interface WorkflowPublishResult {
}
export interface PublishPrefill {
name?: string
description?: string
tags?: string[]
models?: string[]
customNodes?: string[]
thumbnailType?: ThumbnailType
thumbnailUrl?: string
thumbnailComparisonUrl?: string
sampleImageUrls?: string[]
tutorialUrl?: string
metadata?: Record<string, unknown>
}
export type WorkflowPublishStatus =