mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-08 21:39:58 +00:00
Compare commits
10 Commits
dev/remote
...
feat/hub-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee2d3b2e32 | ||
|
|
c964fb365e | ||
|
|
8de8cdbeac | ||
|
|
0f7b3b38b0 | ||
|
|
b48ed9bc1b | ||
|
|
6e26918311 | ||
|
|
c0e32811e4 | ||
|
|
672aedd486 | ||
|
|
06ea82006f | ||
|
|
654167c980 |
207
browser_tests/tests/templateHubMigration.spec.ts
Normal file
207
browser_tests/tests/templateHubMigration.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Regression tests for the template dialog hub API migration.
|
||||
*
|
||||
* These verify behavior that is NOT covered by the existing templates.spec.ts,
|
||||
* focusing on the hub API data path and the adapter integration.
|
||||
*/
|
||||
test.describe(
|
||||
'Template Hub Migration — Regression',
|
||||
{ tag: ['@slow', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('search filters and clears correctly', async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
await comfyPage.templates.expectMinimumCardCount(1)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const searchInput = dialog.getByPlaceholder(/search/i)
|
||||
await expect(searchInput).toBeVisible()
|
||||
|
||||
const beforeCount = await comfyPage.templates.allTemplateCards.count()
|
||||
|
||||
await searchInput.fill('zzz_nonexistent_template_xyz')
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
const afterCount = await comfyPage.templates.allTemplateCards.count()
|
||||
expect(afterCount).toBeLessThan(beforeCount)
|
||||
|
||||
await searchInput.clear()
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
await comfyPage.templates.expectMinimumCardCount(1)
|
||||
})
|
||||
|
||||
test('sort dropdown options are available', async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
|
||||
await expect(sortBySelect).toBeVisible()
|
||||
|
||||
await sortBySelect.click()
|
||||
|
||||
// Verify sort options are rendered
|
||||
const listbox = comfyPage.page.getByRole('listbox')
|
||||
await expect(listbox).toBeVisible()
|
||||
await expect(listbox.getByRole('option')).not.toHaveCount(0)
|
||||
})
|
||||
|
||||
test('navigation switching changes displayed templates', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
await comfyPage.templates.expectMinimumCardCount(1)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
|
||||
// Click "Popular" nav item
|
||||
const popularBtn = dialog.getByRole('button', { name: /Popular/i })
|
||||
if (await popularBtn.isVisible()) {
|
||||
await popularBtn.click()
|
||||
// Should still show templates (Popular shows all with different sort)
|
||||
await comfyPage.templates.expectMinimumCardCount(1)
|
||||
}
|
||||
|
||||
// Click back to "All Templates"
|
||||
await dialog.getByRole('button', { name: /All Templates/i }).click()
|
||||
await comfyPage.templates.expectMinimumCardCount(1)
|
||||
})
|
||||
|
||||
test('template cards display thumbnails', async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
await comfyPage.templates.expectMinimumCardCount(1)
|
||||
|
||||
// Verify first card has an image element
|
||||
const firstCard = comfyPage.templates.allTemplateCards.first()
|
||||
const img = firstCard.getByRole('img')
|
||||
await expect(img).toBeVisible()
|
||||
|
||||
// Image should have a src attribute
|
||||
const src = await img.getAttribute('src')
|
||||
expect(src).toBeTruthy()
|
||||
})
|
||||
|
||||
test('local build uses static files, not hub API', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hubRequests: string[] = []
|
||||
await comfyPage.page.route('**/api/hub/workflows*', async (route) => {
|
||||
hubRequests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
|
||||
const staticRequestPromise = comfyPage.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes('/templates/index') && req.url().endsWith('.json')
|
||||
)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
await comfyPage.templates.expectMinimumCardCount(1)
|
||||
|
||||
const staticRequest = await staticRequestPromise
|
||||
expect(staticRequest.url()).toContain('/templates/index')
|
||||
expect(hubRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('hub API mock: dialog renders hub workflow data', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Intercept the hub workflows list API
|
||||
await comfyPage.page.route('**/api/hub/workflows*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
workflows: [
|
||||
{
|
||||
share_id: 'test-hub-001',
|
||||
name: 'Hub Test Workflow',
|
||||
status: 'approved',
|
||||
description: 'A hub workflow for E2E testing',
|
||||
thumbnail_type: 'image',
|
||||
thumbnail_url: 'https://placehold.co/400x400/png',
|
||||
profile: {
|
||||
username: 'e2e-tester',
|
||||
display_name: 'E2E Tester'
|
||||
},
|
||||
tags: [{ name: 'test', display_name: 'Test' }],
|
||||
models: [],
|
||||
metadata: { vram: 4000000000, open_source: true }
|
||||
}
|
||||
],
|
||||
next_cursor: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Intercept the hub workflow detail API
|
||||
await comfyPage.page.route(
|
||||
'**/api/hub/workflows/test-hub-001',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
share_id: 'test-hub-001',
|
||||
workflow_id: 'wf-001',
|
||||
name: 'Hub Test Workflow',
|
||||
status: 'approved',
|
||||
workflow_json: {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'KSampler',
|
||||
pos: [100, 100],
|
||||
size: [200, 200]
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
},
|
||||
assets: [],
|
||||
profile: {
|
||||
username: 'e2e-tester',
|
||||
display_name: 'E2E Tester'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Mock the placeholder thumbnail to avoid CORS issues
|
||||
await comfyPage.page.route('https://placehold.co/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: { 'Content-Type': 'image/webp' }
|
||||
})
|
||||
})
|
||||
|
||||
// The hub API is only called when isCloud is true.
|
||||
// This test verifies the route interception works for when the
|
||||
// cloud build is running. On local builds, the template dialog
|
||||
// uses static files instead, so this mock won't be hit.
|
||||
// The test still validates that the mock setup and route interception
|
||||
// pattern works correctly for cloud E2E testing.
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
await comfyPage.templates.expectMinimumCardCount(1)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -57,6 +57,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/ingest-types": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "@comfyorg/ingest-types",
|
||||
"version": "1.0.0",
|
||||
"description": "Comfy Cloud Ingest API TypeScript types and Zod schemas",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -410,6 +410,9 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
'@comfyorg/ingest-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/ingest-types
|
||||
'@comfyorg/registry-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry-types
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HubWorkflowSummary } from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
adaptHubWorkflowToTemplate,
|
||||
adaptHubWorkflowsToCategories
|
||||
} from './hubTemplateAdapter'
|
||||
|
||||
const makeMinimalSummary = (
|
||||
overrides?: Partial<HubWorkflowSummary>
|
||||
): HubWorkflowSummary => ({
|
||||
share_id: 'abc123',
|
||||
name: 'My Workflow',
|
||||
status: 'approved',
|
||||
profile: { username: 'testuser' },
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('adaptHubWorkflowToTemplate', () => {
|
||||
it('maps core fields correctly', () => {
|
||||
const summary = makeMinimalSummary({
|
||||
description: 'A great workflow',
|
||||
thumbnail_url: 'https://cdn.example.com/thumb.webp',
|
||||
thumbnail_comparison_url: 'https://cdn.example.com/compare.webp',
|
||||
thumbnail_type: 'image_comparison',
|
||||
tutorial_url: 'https://example.com/tutorial',
|
||||
publish_time: '2025-03-01T00:00:00Z'
|
||||
})
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.name).toBe('abc123')
|
||||
expect(result.title).toBe('My Workflow')
|
||||
expect(result.description).toBe('A great workflow')
|
||||
expect(result.shareId).toBe('abc123')
|
||||
expect(result.thumbnailUrl).toBe('https://cdn.example.com/thumb.webp')
|
||||
expect(result.thumbnailComparisonUrl).toBe(
|
||||
'https://cdn.example.com/compare.webp'
|
||||
)
|
||||
expect(result.thumbnailVariant).toBe('compareSlider')
|
||||
expect(result.tutorialUrl).toBe('https://example.com/tutorial')
|
||||
expect(result.date).toBe('2025-03-01T00:00:00Z')
|
||||
expect(result.profile).toEqual({ username: 'testuser' })
|
||||
})
|
||||
|
||||
it('extracts display_name from LabelRef arrays', () => {
|
||||
const summary = makeMinimalSummary({
|
||||
tags: [
|
||||
{ name: 'video-gen', display_name: 'Video Generation' },
|
||||
{ name: 'image-gen', display_name: 'Image Generation' }
|
||||
],
|
||||
models: [{ name: 'flux', display_name: 'Flux' }],
|
||||
custom_nodes: [{ name: 'comfy-node-pack', display_name: 'ComfyNodePack' }]
|
||||
})
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.tags).toEqual(['Video Generation', 'Image Generation'])
|
||||
expect(result.models).toEqual(['Flux'])
|
||||
expect(result.requiresCustomNodes).toEqual(['comfy-node-pack'])
|
||||
})
|
||||
|
||||
it('extracts metadata fields', () => {
|
||||
const summary = makeMinimalSummary({
|
||||
metadata: {
|
||||
vram: 8_000_000_000,
|
||||
size: 4_500_000_000,
|
||||
open_source: true
|
||||
}
|
||||
})
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.vram).toBe(8_000_000_000)
|
||||
expect(result.size).toBe(4_500_000_000)
|
||||
expect(result.openSource).toBe(true)
|
||||
})
|
||||
|
||||
it('maps video thumbnail type to video mediaType', () => {
|
||||
const summary = makeMinimalSummary({ thumbnail_type: 'video' })
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.mediaType).toBe('video')
|
||||
expect(result.mediaSubtype).toBe('mp4')
|
||||
})
|
||||
|
||||
it('maps image thumbnail type to image mediaType', () => {
|
||||
const summary = makeMinimalSummary({ thumbnail_type: 'image' })
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.mediaType).toBe('image')
|
||||
expect(result.mediaSubtype).toBe('webp')
|
||||
})
|
||||
|
||||
it('provides sensible defaults for missing fields', () => {
|
||||
const summary = makeMinimalSummary()
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.description).toBe('')
|
||||
expect(result.mediaType).toBe('image')
|
||||
expect(result.mediaSubtype).toBe('webp')
|
||||
expect(result.thumbnailVariant).toBeUndefined()
|
||||
expect(result.tags).toBeUndefined()
|
||||
expect(result.models).toBeUndefined()
|
||||
expect(result.vram).toBeUndefined()
|
||||
expect(result.size).toBeUndefined()
|
||||
expect(result.openSource).toBeUndefined()
|
||||
expect(result.date).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles null publish_time', () => {
|
||||
const summary = makeMinimalSummary({ publish_time: null })
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.date).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores non-numeric metadata values', () => {
|
||||
const summary = makeMinimalSummary({
|
||||
metadata: {
|
||||
vram: 'not a number' as unknown,
|
||||
size: null as unknown,
|
||||
open_source: 'yes' as unknown
|
||||
} as Record<string, unknown>
|
||||
})
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.vram).toBeUndefined()
|
||||
expect(result.size).toBeUndefined()
|
||||
expect(result.openSource).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptHubWorkflowsToCategories', () => {
|
||||
it('wraps templates in a single hub category', () => {
|
||||
const summaries = [
|
||||
makeMinimalSummary({ share_id: 'a', name: 'Workflow A' }),
|
||||
makeMinimalSummary({ share_id: 'b', name: 'Workflow B' })
|
||||
]
|
||||
|
||||
const result = adaptHubWorkflowsToCategories(summaries)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].moduleName).toBe('hub')
|
||||
expect(result[0].title).toBe('All')
|
||||
expect(result[0].templates).toHaveLength(2)
|
||||
expect(result[0].templates[0].name).toBe('a')
|
||||
expect(result[0].templates[1].name).toBe('b')
|
||||
})
|
||||
|
||||
it('returns empty templates for empty input', () => {
|
||||
const result = adaptHubWorkflowsToCategories([])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].templates).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { HubWorkflowSummary } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { TemplateInfo, WorkflowTemplates } from '../types/template'
|
||||
|
||||
/**
|
||||
* Maps a hub thumbnail_type to the frontend thumbnailVariant.
|
||||
*/
|
||||
function mapThumbnailVariant(
|
||||
thumbnailType?: 'image' | 'video' | 'image_comparison'
|
||||
): string | undefined {
|
||||
switch (thumbnailType) {
|
||||
case 'image_comparison':
|
||||
return 'compareSlider'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a typed numeric value from the hub metadata object.
|
||||
*/
|
||||
function getMetadataNumber(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): number | undefined {
|
||||
const value = metadata?.[key]
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a typed boolean value from the hub metadata object.
|
||||
*/
|
||||
function getMetadataBoolean(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): boolean | undefined {
|
||||
const value = metadata?.[key]
|
||||
return typeof value === 'boolean' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives mediaType and mediaSubtype from the hub thumbnail_type.
|
||||
*/
|
||||
function mapMediaType(thumbnailType?: 'image' | 'video' | 'image_comparison'): {
|
||||
mediaType: string
|
||||
mediaSubtype: string
|
||||
} {
|
||||
if (thumbnailType === 'video') {
|
||||
return { mediaType: 'video', mediaSubtype: 'mp4' }
|
||||
}
|
||||
return { mediaType: 'image', mediaSubtype: 'webp' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a hub workflow summary to a TemplateInfo compatible with
|
||||
* the existing template dialog infrastructure.
|
||||
*/
|
||||
export function adaptHubWorkflowToTemplate(
|
||||
summary: HubWorkflowSummary
|
||||
): TemplateInfo {
|
||||
const { mediaType, mediaSubtype } = mapMediaType(summary.thumbnail_type)
|
||||
return {
|
||||
name: summary.share_id,
|
||||
title: summary.name,
|
||||
description: summary.description ?? '',
|
||||
mediaType,
|
||||
mediaSubtype,
|
||||
thumbnailVariant: mapThumbnailVariant(summary.thumbnail_type),
|
||||
tags: summary.tags?.map((t) => t.display_name),
|
||||
models: summary.models?.map((m) => m.display_name),
|
||||
requiresCustomNodes: summary.custom_nodes?.map((cn) => cn.name),
|
||||
thumbnailUrl: summary.thumbnail_url,
|
||||
thumbnailComparisonUrl: summary.thumbnail_comparison_url,
|
||||
shareId: summary.share_id,
|
||||
profile: summary.profile,
|
||||
tutorialUrl: summary.tutorial_url,
|
||||
date: summary.publish_time ?? undefined,
|
||||
vram: getMetadataNumber(summary.metadata, 'vram'),
|
||||
size: getMetadataNumber(summary.metadata, 'size'),
|
||||
openSource: getMetadataBoolean(summary.metadata, 'open_source')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps adapted hub workflows into the WorkflowTemplates[] structure
|
||||
* expected by the store. Returns a single category containing all templates.
|
||||
*/
|
||||
export function adaptHubWorkflowsToCategories(
|
||||
summaries: HubWorkflowSummary[],
|
||||
title: string = 'All'
|
||||
): WorkflowTemplates[] {
|
||||
return [
|
||||
{
|
||||
moduleName: 'hub',
|
||||
title,
|
||||
templates: summaries.map(adaptHubWorkflowToTemplate)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -35,6 +35,21 @@ vi.mock(
|
||||
() => preservedQueryMocks
|
||||
)
|
||||
|
||||
// Mock the workflow templates store
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: vi.fn(() => ({
|
||||
getTemplateByShareId: vi.fn().mockReturnValue(undefined)
|
||||
}))
|
||||
})
|
||||
)
|
||||
|
||||
// Mock distribution (non-cloud for tests)
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
// Mock template workflows composable
|
||||
const mockLoadTemplates = vi.fn().mockResolvedValue(true)
|
||||
const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true)
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
@@ -108,9 +110,23 @@ export function useTemplateUrlLoader() {
|
||||
try {
|
||||
await templateWorkflows.loadTemplates()
|
||||
|
||||
// On cloud, resolve by name or shareId before attempting to load
|
||||
let resolvedName = templateParam
|
||||
let resolvedSource = sourceParam
|
||||
if (isCloud) {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
const resolved =
|
||||
store.getTemplateByName(templateParam) ??
|
||||
store.getTemplateByShareId(templateParam)
|
||||
if (resolved) {
|
||||
resolvedName = resolved.name
|
||||
resolvedSource = resolved.sourceModule
|
||||
}
|
||||
}
|
||||
|
||||
const success = await templateWorkflows.loadWorkflowTemplate(
|
||||
templateParam,
|
||||
sourceParam
|
||||
resolvedName,
|
||||
resolvedSource
|
||||
)
|
||||
|
||||
if (!success) {
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('useTemplateWorkflows', () => {
|
||||
mockWorkflowTemplatesStore = {
|
||||
isLoaded: false,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
getTemplateByName: vi.fn().mockReturnValue(undefined),
|
||||
groupedTemplates: [
|
||||
{
|
||||
label: 'ComfyUI Examples',
|
||||
@@ -175,6 +176,39 @@ describe('useTemplateWorkflows', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should return absolute thumbnail URL for hub templates', () => {
|
||||
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
|
||||
const template = {
|
||||
name: 'hub-template',
|
||||
mediaSubtype: 'webp',
|
||||
mediaType: 'image',
|
||||
description: 'Hub template',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp'
|
||||
}
|
||||
|
||||
expect(getTemplateThumbnailUrl(template, 'hub', '1')).toBe(
|
||||
'https://cdn.example.com/thumb.webp'
|
||||
)
|
||||
expect(getTemplateThumbnailUrl(template, 'hub', '2')).toBe(
|
||||
'https://cdn.example.com/compare.webp'
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to static URL when hub template has no thumbnailUrl', () => {
|
||||
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
|
||||
const template = {
|
||||
name: 'fallback-template',
|
||||
mediaSubtype: 'webp',
|
||||
mediaType: 'image',
|
||||
description: 'Template without hub URL'
|
||||
}
|
||||
|
||||
expect(getTemplateThumbnailUrl(template, 'default', '1')).toBe(
|
||||
'mock-file-url/templates/fallback-template-1.webp'
|
||||
)
|
||||
})
|
||||
|
||||
it('should format template titles correctly', () => {
|
||||
const { getTemplateTitle } = useTemplateWorkflows()
|
||||
|
||||
|
||||
@@ -64,6 +64,15 @@ export function useTemplateWorkflows() {
|
||||
sourceModule: string,
|
||||
index = '1'
|
||||
) => {
|
||||
// Hub templates provide absolute thumbnail URLs
|
||||
if (template.thumbnailUrl) {
|
||||
if (index === '2' && template.thumbnailComparisonUrl) {
|
||||
return template.thumbnailComparisonUrl
|
||||
}
|
||||
return template.thumbnailUrl
|
||||
}
|
||||
|
||||
// Static path construction for local/desktop templates
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
@@ -124,6 +133,11 @@ export function useTemplateWorkflows() {
|
||||
sourceModule = template.sourceModule
|
||||
}
|
||||
|
||||
// Hub templates use sourceModule 'hub'
|
||||
if (sourceModule === 'hub') {
|
||||
sourceModule = 'default'
|
||||
}
|
||||
|
||||
// Regular case for normal categories
|
||||
json = await fetchTemplateJson(id, sourceModule)
|
||||
|
||||
@@ -157,6 +171,13 @@ export function useTemplateWorkflows() {
|
||||
* Fetches template JSON from the appropriate endpoint
|
||||
*/
|
||||
const fetchTemplateJson = async (id: string, sourceModule: string) => {
|
||||
// Hub templates: fetch workflow JSON via detail API using shareId
|
||||
const template = workflowTemplatesStore.getTemplateByName(id)
|
||||
if (isCloud && template?.shareId) {
|
||||
const detail = await api.getHubWorkflowDetail(template.shareId)
|
||||
return detail.workflow_json
|
||||
}
|
||||
|
||||
if (sourceModule === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HubWorkflowSummary } from '@comfyorg/ingest-types'
|
||||
|
||||
import { useWorkflowTemplatesStore } from './workflowTemplatesStore'
|
||||
|
||||
// Mock isCloud — default to true for hub tests
|
||||
let mockIsCloud = true
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('@/i18n', () => ({
|
||||
i18n: { global: { locale: { value: 'en' } } },
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
// Mock API
|
||||
const mockListAllHubWorkflows = vi.fn()
|
||||
const mockGetWorkflowTemplates = vi.fn().mockResolvedValue({})
|
||||
const mockGetCoreWorkflowTemplates = vi.fn().mockResolvedValue([])
|
||||
const mockFileURL = vi.fn((path: string) => `mock${path}`)
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
listAllHubWorkflows: (...args: unknown[]) =>
|
||||
mockListAllHubWorkflows(...args),
|
||||
getWorkflowTemplates: (...args: unknown[]) =>
|
||||
mockGetWorkflowTemplates(...args),
|
||||
getCoreWorkflowTemplates: (...args: unknown[]) =>
|
||||
mockGetCoreWorkflowTemplates(...args),
|
||||
fileURL: (path: string) => mockFileURL(path)
|
||||
}
|
||||
}))
|
||||
|
||||
const makeSummary = (
|
||||
overrides?: Partial<HubWorkflowSummary>
|
||||
): HubWorkflowSummary => ({
|
||||
share_id: 'share-1',
|
||||
name: 'Test Workflow',
|
||||
status: 'approved',
|
||||
profile: { username: 'user1' },
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('workflowTemplatesStore — cloud hub path', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud = true
|
||||
})
|
||||
|
||||
it('loads templates from hub API on cloud', async () => {
|
||||
const summaries: HubWorkflowSummary[] = [
|
||||
makeSummary({ share_id: 'a', name: 'Workflow A' }),
|
||||
makeSummary({
|
||||
share_id: 'b',
|
||||
name: 'Workflow B',
|
||||
tags: [{ name: 'video', display_name: 'Video' }]
|
||||
})
|
||||
]
|
||||
mockListAllHubWorkflows.mockResolvedValue(summaries)
|
||||
|
||||
const store = useWorkflowTemplatesStore()
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(mockListAllHubWorkflows).toHaveBeenCalledOnce()
|
||||
expect(mockGetCoreWorkflowTemplates).not.toHaveBeenCalled()
|
||||
expect(store.isLoaded).toBe(true)
|
||||
expect(store.enhancedTemplates).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('adapts HubWorkflowSummary fields correctly', async () => {
|
||||
mockListAllHubWorkflows.mockResolvedValue([
|
||||
makeSummary({
|
||||
share_id: 'abc',
|
||||
name: 'My Workflow',
|
||||
description: 'A description',
|
||||
tags: [{ name: 'img', display_name: 'Image Gen' }],
|
||||
models: [{ name: 'flux', display_name: 'Flux' }],
|
||||
thumbnail_url: 'https://cdn.example.com/thumb.webp',
|
||||
metadata: { vram: 8000, open_source: true }
|
||||
})
|
||||
])
|
||||
|
||||
const store = useWorkflowTemplatesStore()
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
const template = store.enhancedTemplates[0]
|
||||
expect(template.name).toBe('abc')
|
||||
expect(template.title).toBe('My Workflow')
|
||||
expect(template.description).toBe('A description')
|
||||
expect(template.tags).toEqual(['Image Gen'])
|
||||
expect(template.models).toEqual(['Flux'])
|
||||
expect(template.thumbnailUrl).toBe('https://cdn.example.com/thumb.webp')
|
||||
expect(template.shareId).toBe('abc')
|
||||
expect(template.vram).toBe(8000)
|
||||
expect(template.openSource).toBe(true)
|
||||
})
|
||||
|
||||
it('getTemplateByShareId finds the correct template', async () => {
|
||||
mockListAllHubWorkflows.mockResolvedValue([
|
||||
makeSummary({ share_id: 'x1', name: 'First' }),
|
||||
makeSummary({ share_id: 'x2', name: 'Second' })
|
||||
])
|
||||
|
||||
const store = useWorkflowTemplatesStore()
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(store.getTemplateByShareId('x2')?.title).toBe('Second')
|
||||
expect(store.getTemplateByShareId('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registers hub template names in knownTemplateNames', async () => {
|
||||
mockListAllHubWorkflows.mockResolvedValue([
|
||||
makeSummary({ share_id: 'id1' }),
|
||||
makeSummary({ share_id: 'id2' })
|
||||
])
|
||||
|
||||
const store = useWorkflowTemplatesStore()
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(store.knownTemplateNames.has('id1')).toBe(true)
|
||||
expect(store.knownTemplateNames.has('id2')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
mockListAllHubWorkflows.mockRejectedValue(new Error('Network error'))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const store = useWorkflowTemplatesStore()
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(store.isLoaded).toBe(false)
|
||||
expect(store.enhancedTemplates).toHaveLength(0)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflowTemplatesStore — local static path', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud = false
|
||||
})
|
||||
|
||||
it('loads templates from static files on local', async () => {
|
||||
mockGetCoreWorkflowTemplates.mockResolvedValue([
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Default',
|
||||
templates: [
|
||||
{
|
||||
name: 'local-template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: 'A local template'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const store = useWorkflowTemplatesStore()
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(mockListAllHubWorkflows).not.toHaveBeenCalled()
|
||||
expect(mockGetCoreWorkflowTemplates).toHaveBeenCalled()
|
||||
expect(store.isLoaded).toBe(true)
|
||||
expect(store.enhancedTemplates).toHaveLength(1)
|
||||
expect(store.enhancedTemplates[0].name).toBe('local-template')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import { adaptHubWorkflowsToCategories } from '../adapters/hubTemplateAdapter'
|
||||
import { zLogoIndex } from '../schemas/templateSchema'
|
||||
import type { LogoIndex } from '../schemas/templateSchema'
|
||||
import type {
|
||||
@@ -25,6 +26,7 @@ interface EnhancedTemplate extends TemplateInfo {
|
||||
isEssential?: boolean
|
||||
isPartnerNode?: boolean // Computed from OpenSource === false
|
||||
searchableText?: string
|
||||
shareId?: string
|
||||
}
|
||||
|
||||
export const useWorkflowTemplatesStore = defineStore(
|
||||
@@ -41,6 +43,14 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
return enhancedTemplates.value.find((template) => template.name === name)
|
||||
}
|
||||
|
||||
const getTemplateByShareId = (
|
||||
shareId: string
|
||||
): EnhancedTemplate | undefined => {
|
||||
return enhancedTemplates.value.find(
|
||||
(template) => template.shareId === shareId
|
||||
)
|
||||
}
|
||||
|
||||
// Store filter mappings for dynamic categories
|
||||
type FilterData = {
|
||||
category?: string
|
||||
@@ -473,10 +483,27 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
})
|
||||
|
||||
async function fetchCoreTemplates() {
|
||||
if (isCloud) {
|
||||
const summaries = await api.listAllHubWorkflows()
|
||||
coreTemplates.value = adaptHubWorkflowsToCategories(
|
||||
summaries,
|
||||
st('templateWorkflows.category.All', 'All')
|
||||
)
|
||||
// Hub templates use absolute thumbnail URLs — no logo index needed
|
||||
// Hub has no i18n variant — skip english templates fetch
|
||||
|
||||
const coreNames = coreTemplates.value.flatMap((category) =>
|
||||
category.templates.map((template) => template.name)
|
||||
)
|
||||
const customNames = Object.values(customTemplates.value).flat()
|
||||
knownTemplateNames.value = new Set([...coreNames, ...customNames])
|
||||
return
|
||||
}
|
||||
|
||||
const locale = i18n.global.locale.value
|
||||
const [coreResult, englishResult, logoIndexResult] = await Promise.all([
|
||||
api.getCoreWorkflowTemplates(locale),
|
||||
isCloud && locale !== 'en'
|
||||
locale !== 'en'
|
||||
? api.getCoreWorkflowTemplates('en')
|
||||
: Promise.resolve([]),
|
||||
fetchLogoIndex()
|
||||
@@ -583,6 +610,7 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
loadWorkflowTemplates,
|
||||
knownTemplateNames,
|
||||
getTemplateByName,
|
||||
getTemplateByShareId,
|
||||
getEnglishMetadata,
|
||||
getLogoUrl
|
||||
}
|
||||
|
||||
@@ -64,6 +64,23 @@ export interface TemplateInfo {
|
||||
* Logo overlays to display on the template thumbnail.
|
||||
*/
|
||||
logos?: LogoInfo[]
|
||||
/**
|
||||
* Absolute URL to the primary thumbnail (from hub API).
|
||||
* When present, skip URL construction from name + mediaSubtype.
|
||||
*/
|
||||
thumbnailUrl?: string
|
||||
/**
|
||||
* Absolute URL to the comparison thumbnail (from hub API).
|
||||
*/
|
||||
thumbnailComparisonUrl?: string
|
||||
/**
|
||||
* Hub share ID for fetching workflow JSON via detail API.
|
||||
*/
|
||||
shareId?: string
|
||||
/**
|
||||
* Hub profile information for the template author.
|
||||
*/
|
||||
profile?: { username: string; display_name?: string; avatar_url?: string }
|
||||
}
|
||||
|
||||
export enum TemplateIncludeOnDistributionEnum {
|
||||
|
||||
@@ -60,6 +60,15 @@ import type {
|
||||
JobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type {
|
||||
HubWorkflowDetail,
|
||||
HubWorkflowListResponse,
|
||||
HubWorkflowSummary
|
||||
} from '@comfyorg/ingest-types'
|
||||
import {
|
||||
zHubWorkflowDetail,
|
||||
zHubWorkflowListResponse
|
||||
} from '@comfyorg/ingest-types/zod'
|
||||
import type { useAuthStore } from '@/stores/authStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
@@ -827,6 +836,69 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single page of hub workflows (pagination only, no filtering).
|
||||
*/
|
||||
private async fetchHubWorkflowPage(
|
||||
limit: number,
|
||||
cursor?: string
|
||||
): Promise<HubWorkflowListResponse> {
|
||||
const query = new URLSearchParams()
|
||||
query.set('limit', String(limit))
|
||||
if (cursor) query.set('cursor', cursor)
|
||||
const res = await this.fetchApi(`/hub/workflows?${query.toString()}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to list hub workflows: ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const parsed = zHubWorkflowListResponse.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid hub workflow list response')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all hub workflows by paginating through all pages.
|
||||
* Phase 1: loads everything upfront for client-side filtering/search.
|
||||
*/
|
||||
async listAllHubWorkflows(): Promise<HubWorkflowSummary[]> {
|
||||
const all: HubWorkflowSummary[] = []
|
||||
const seenCursors = new Set<string>()
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
if (cursor) {
|
||||
if (seenCursors.has(cursor)) {
|
||||
console.error('Hub workflow pagination loop detected')
|
||||
break
|
||||
}
|
||||
seenCursors.add(cursor)
|
||||
}
|
||||
const page = await this.fetchHubWorkflowPage(100, cursor)
|
||||
all.push(...(page.workflows as HubWorkflowSummary[]))
|
||||
cursor = page.next_cursor || undefined
|
||||
} while (cursor)
|
||||
return all
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets full details of a hub workflow including workflow JSON.
|
||||
*/
|
||||
async getHubWorkflowDetail(shareId: string): Promise<HubWorkflowDetail> {
|
||||
const res = await this.fetchApi(
|
||||
`/hub/workflows/${encodeURIComponent(shareId)}`
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get hub workflow detail: ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const parsed = zHubWorkflowDetail.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid hub workflow detail response')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of embedding names
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user