Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions browser_tests/tests/templateHubMigration.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
Comment on lines +5 to +12
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would still make sense to just put these in the existing template test files/modules/folders instead of distinguishing them as separate.

{ 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')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the dialog and buttons be page objects like comfyPage.templates.popularBtn? Same goes for other test cases in this file


// 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 ({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to put this data in the fixtures/data and potentially the mock routing in the templates page object or helper if appropriate.

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)
Comment on lines +196 to +204
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can now write tests targeting cloud after #10546

})
}
)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
1 change: 1 addition & 0 deletions packages/ingest-types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

163 changes: 163 additions & 0 deletions src/platform/workflow/templates/adapters/hubTemplateAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading