-
Notifications
You must be signed in to change notification settings - Fork 539
feat(templates): migrate cloud template dialog to hub API #10675
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
654167c
06ea820
672aedd
c0e3281
6e26918
b48ed9b
0f7b3b3
8de8cdb
c964fb3
ee2d3b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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', | ||
| { 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') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't the dialog and buttons be page objects like |
||
|
|
||
| // 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 ({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can now write tests targeting cloud after #10546 |
||
| }) | ||
| } | ||
| ) | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
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.