mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 11:44:10 +00:00
The "open tutorial" button only existed in the DOM when the template card as actively hovered. For reasons I can not comprehend (probably overzealous pointer handlers somewhere), the act of clicking on the button would fire a mouseleave event. This caused the button to disappear for the exact moment it was clicked alike to a mischievous dondurma vendor. This is resolved by keeping the button always in DOM, but making it invisible when the card isn't hovered. The PR also removes a deeply nested `v-bind='$attrs'`. I'm assuming it must be a mistake that attributes applied to the entire template selector dialogue would be bound to every deeply nested tutorial button on individual workflow cards.
508 lines
17 KiB
TypeScript
508 lines
17 KiB
TypeScript
import type { Page } from '@playwright/test'
|
|
import { expect } from '@playwright/test'
|
|
|
|
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
|
|
import { getWav } from '@e2e/fixtures/components/AudioPreview'
|
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
|
import { TestIds } from '@e2e/fixtures/selectors'
|
|
|
|
async function checkTemplateFileExists(
|
|
page: Page,
|
|
filename: string
|
|
): Promise<boolean> {
|
|
const response = await page.request.head(
|
|
new URL(`/templates/${filename}`, page.url()).toString()
|
|
)
|
|
return response.ok()
|
|
}
|
|
|
|
test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
|
test('should have a JSON workflow file for each template', async ({
|
|
comfyPage
|
|
}) => {
|
|
test.slow()
|
|
const templates = await comfyPage.templates.getAllTemplates()
|
|
for (const template of templates) {
|
|
const exists = await checkTemplateFileExists(
|
|
comfyPage.page,
|
|
`${template.name}.json`
|
|
)
|
|
expect(exists, `Missing workflow: ${template.name}`).toBe(true)
|
|
}
|
|
})
|
|
|
|
// Flaky: /templates is proxied to an external server, so thumbnail
|
|
// availability varies across CI runs.
|
|
// FIX: Make hermetic — fixture index.json and thumbnail responses via
|
|
// page.route(), and change checkTemplateFileExists to use browser-context
|
|
// fetch (page.request.head bypasses Playwright routing).
|
|
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
|
|
// oxlint-disable-next-line playwright/no-skipped-test -- https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
|
|
test.skip('should have all required thumbnail media for each template', async ({
|
|
comfyPage
|
|
}) => {
|
|
test.slow()
|
|
const templates = await comfyPage.templates.getAllTemplates()
|
|
for (const template of templates) {
|
|
const { name, mediaSubtype, thumbnailVariant } = template
|
|
const baseMedia = `${name}-1.${mediaSubtype}`
|
|
|
|
// Check base thumbnail
|
|
const baseExists = await checkTemplateFileExists(
|
|
comfyPage.page,
|
|
baseMedia
|
|
)
|
|
expect(baseExists, `Missing base thumbnail: ${baseMedia}`).toBe(true)
|
|
|
|
// Check second thumbnail for variants that need it
|
|
if (
|
|
thumbnailVariant === 'compareSlider' ||
|
|
thumbnailVariant === 'hoverDissolve'
|
|
) {
|
|
const secondMedia = `${name}-2.${mediaSubtype}`
|
|
const secondExists = await checkTemplateFileExists(
|
|
comfyPage.page,
|
|
secondMedia
|
|
)
|
|
expect(
|
|
secondExists,
|
|
`Missing second thumbnail: ${secondMedia} required for ${thumbnailVariant}`
|
|
).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('Can load template workflows', async ({ comfyPage }) => {
|
|
// Clear the workflow
|
|
await comfyPage.menu.workflowsTab.open()
|
|
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
|
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
|
|
|
// Load a template
|
|
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
|
await expect(comfyPage.templates.content).toBeVisible()
|
|
|
|
await comfyPage.page
|
|
.getByRole('button', { name: 'Getting Started' })
|
|
.click()
|
|
await comfyPage.templates.loadTemplate('default')
|
|
await expect(comfyPage.templates.content).toBeHidden()
|
|
|
|
// Ensure we now have some nodes
|
|
await expect
|
|
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
|
.toBeGreaterThan(0)
|
|
})
|
|
|
|
test('dialog should be automatically shown to first-time users', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Set the tutorial as not completed to mark the user as a first-time user
|
|
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
|
|
|
// Load the page
|
|
await comfyPage.setup({ clearStorage: true })
|
|
|
|
// Expect the templates dialog to be shown
|
|
await expect(comfyPage.templates.content).toBeVisible()
|
|
})
|
|
|
|
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.page.route(
|
|
'**/workflows/published/test-share-id',
|
|
async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
share_id: 'test-share-id',
|
|
workflow_id: 'wf-1',
|
|
name: 'Shared Workflow',
|
|
listed: true,
|
|
publish_time: new Date().toISOString(),
|
|
workflow_json: {
|
|
version: 0.4,
|
|
nodes: [],
|
|
links: [],
|
|
groups: [],
|
|
config: {},
|
|
extra: {}
|
|
},
|
|
assets: []
|
|
})
|
|
})
|
|
}
|
|
)
|
|
|
|
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
|
|
|
await comfyPage.setup({
|
|
clearStorage: true,
|
|
url: '/?share=test-share-id'
|
|
})
|
|
|
|
await expect(
|
|
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
|
).toBeVisible()
|
|
|
|
await expect(comfyPage.templates.content).toBeHidden()
|
|
})
|
|
|
|
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
|
|
|
|
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
|
|
|
const dialog = comfyPage.templatesDialog.filterByHeading('Modèles')
|
|
await expect(dialog).toBeVisible()
|
|
|
|
// Validate that French-localized strings from the templates index are rendered
|
|
await expect(
|
|
dialog.getByRole('heading', { name: 'Modèles', exact: true })
|
|
).toBeVisible()
|
|
await expect(
|
|
dialog.getByRole('button', { name: 'Tous les modèles', exact: true })
|
|
).toBeVisible()
|
|
|
|
// Ensure the English fallback copy is not shown anywhere
|
|
await expect(
|
|
comfyPage.page.getByText('All Templates', { exact: true })
|
|
).toHaveCount(0)
|
|
})
|
|
|
|
test('Falls back to English templates when locale file not found', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Pick a shipped LTR locale and simulate its template index returning 404.
|
|
// (Previously this test used 'de', but unsupported locales are now
|
|
// clamped to 'en' at boot so they never hit the template fallback path.
|
|
// 'fa' would also work but flips document.dir to rtl, which can leak
|
|
// into adjacent specs in the same worker.)
|
|
const locale = 'tr'
|
|
|
|
await comfyPage.page.route(
|
|
`**/templates/index.${locale}.json`,
|
|
async (route) => {
|
|
await route.fulfill({
|
|
status: 404,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
body: 'Not Found'
|
|
})
|
|
}
|
|
)
|
|
|
|
await comfyPage.page.route('**/templates/index.json', (route) =>
|
|
route.continue()
|
|
)
|
|
|
|
await comfyPage.settings.setSetting('Comfy.Locale', locale)
|
|
|
|
const localeRequestPromise = comfyPage.page.waitForRequest(
|
|
`**/templates/index.${locale}.json`
|
|
)
|
|
const englishRequestPromise = comfyPage.page.waitForRequest(
|
|
'**/templates/index.json'
|
|
)
|
|
|
|
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
|
await expect(comfyPage.templates.content).toBeVisible()
|
|
|
|
const localeRequest = await localeRequestPromise
|
|
const englishRequest = await englishRequestPromise
|
|
|
|
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
|
|
expect(englishRequest.url()).toContain('templates/index.json')
|
|
|
|
// Assert on rendered content, not just the container — the container
|
|
// testid is present even when the dialog body is empty, which would let
|
|
// a regression where the fallback fetch succeeds but no cards render
|
|
// pass silently.
|
|
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
|
|
})
|
|
|
|
test('template cards are dynamically sized and responsive', async ({
|
|
comfyPage
|
|
}) => {
|
|
// Open templates dialog
|
|
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
|
await comfyPage.templates.content.waitFor({ state: 'visible' })
|
|
|
|
const templateGrid = comfyPage.page.getByTestId(
|
|
'template-workflows-content'
|
|
)
|
|
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
|
|
|
|
await comfyPage.templates.expectMinimumCardCount(1)
|
|
await expect(templateGrid).toBeVisible()
|
|
await expect(nav).toBeVisible() // Nav should be visible at desktop size
|
|
|
|
const mobileSize = { width: 640, height: 800 }
|
|
await comfyPage.page.setViewportSize(mobileSize)
|
|
await comfyPage.templates.expectMinimumCardCount(1)
|
|
await expect(templateGrid).toBeVisible()
|
|
// Nav header is clipped by overflow-hidden parent at mobile size
|
|
await expect(nav).not.toBeInViewport()
|
|
|
|
const tabletSize = { width: 1024, height: 800 }
|
|
await comfyPage.page.setViewportSize(tabletSize)
|
|
await comfyPage.templates.expectMinimumCardCount(1)
|
|
await expect(templateGrid).toBeVisible()
|
|
await expect(nav).toBeVisible() // Nav should be visible at tablet size
|
|
})
|
|
|
|
test(
|
|
'select components in filter bar render correctly',
|
|
{ tag: '@screenshot' },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
|
await expect(comfyPage.templates.content).toBeVisible()
|
|
|
|
// Wait for filter bar select components to render
|
|
const sortBySelect = comfyPage.templatesDialog.getCombobox(/Sort/)
|
|
await expect(sortBySelect).toBeVisible()
|
|
|
|
// Screenshot the filter bar containing MultiSelect and SingleSelect
|
|
const filterBar = sortBySelect.locator(
|
|
'xpath=ancestor::div[contains(@class, "justify-between")]'
|
|
)
|
|
await expect(filterBar).toHaveScreenshot(
|
|
'template-filter-bar-select-components.png',
|
|
{
|
|
mask: [comfyPage.page.locator('.p-toast')]
|
|
}
|
|
)
|
|
}
|
|
)
|
|
|
|
test(
|
|
'template cards descriptions adjust height dynamically',
|
|
{ tag: '@screenshot' },
|
|
async ({ comfyPage }) => {
|
|
// Setup test by intercepting templates response to inject cards with varying description lengths
|
|
await comfyPage.page.route(
|
|
'**/templates/index.json',
|
|
async (route, _) => {
|
|
const response: WorkflowTemplates[] = [
|
|
{
|
|
moduleName: 'default',
|
|
title: 'Test Templates',
|
|
type: 'image',
|
|
templates: [
|
|
{
|
|
name: 'short-description',
|
|
title: 'Short Description',
|
|
mediaType: 'image',
|
|
mediaSubtype: 'webp',
|
|
description: 'This is a short description.'
|
|
},
|
|
{
|
|
name: 'medium-description',
|
|
title: 'Medium Description',
|
|
mediaType: 'image',
|
|
mediaSubtype: 'webp',
|
|
description:
|
|
'This is a medium length description that should take up two lines on most displays.'
|
|
},
|
|
{
|
|
name: 'long-description',
|
|
title: 'Long Description',
|
|
mediaType: 'image',
|
|
mediaSubtype: 'webp',
|
|
description:
|
|
'This is a much longer description that should definitely wrap to multiple lines. It contains enough text to demonstrate how the cards handle varying amounts of content while maintaining a consistent layout grid.'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
await route.fulfill({
|
|
status: 200,
|
|
body: JSON.stringify(response),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store'
|
|
}
|
|
})
|
|
}
|
|
)
|
|
|
|
// Mock the thumbnail images to avoid 404s
|
|
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
|
const headers = {
|
|
'Content-Type': 'image/webp',
|
|
'Cache-Control': 'no-store'
|
|
}
|
|
await route.fulfill({
|
|
status: 200,
|
|
path: 'browser_tests/assets/example.webp',
|
|
headers
|
|
})
|
|
})
|
|
|
|
// Open templates dialog
|
|
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
|
await expect(comfyPage.templates.content).toBeVisible()
|
|
|
|
// Wait for cards to load
|
|
await expect(
|
|
comfyPage.page.getByTestId('template-workflow-short-description')
|
|
).toBeVisible()
|
|
|
|
// Verify all three cards with different descriptions are visible
|
|
const shortDescCard = comfyPage.page.getByTestId(
|
|
'template-workflow-short-description'
|
|
)
|
|
const mediumDescCard = comfyPage.page.getByTestId(
|
|
'template-workflow-medium-description'
|
|
)
|
|
const longDescCard = comfyPage.page.getByTestId(
|
|
'template-workflow-long-description'
|
|
)
|
|
|
|
await expect(shortDescCard).toBeVisible()
|
|
await expect(mediumDescCard).toBeVisible()
|
|
await expect(longDescCard).toBeVisible()
|
|
|
|
// Verify descriptions are visible and have line-clamp class
|
|
// The description is in a p tag with text-muted class
|
|
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
|
|
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
|
|
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
|
|
|
|
await expect(shortDesc).toContainText('short description')
|
|
await expect(mediumDesc).toContainText('medium length description')
|
|
await expect(longDesc).toContainText('much longer description')
|
|
|
|
// Verify grid layout maintains consistency
|
|
const templateGrid = comfyPage.page.getByTestId(
|
|
'template-workflows-content'
|
|
)
|
|
await expect(templateGrid).toBeVisible()
|
|
await expect(templateGrid).toHaveScreenshot(
|
|
'template-grid-varying-content.png'
|
|
)
|
|
}
|
|
)
|
|
|
|
test(
|
|
'template cards display overlay tags correctly',
|
|
{ tag: '@screenshot' },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
|
const response = [
|
|
{
|
|
moduleName: 'default',
|
|
title: 'Test Templates',
|
|
type: 'image',
|
|
templates: [
|
|
{
|
|
name: 'tagged-template',
|
|
title: 'Tagged Template',
|
|
mediaType: 'image',
|
|
mediaSubtype: 'webp',
|
|
description: 'A template with tags.',
|
|
tags: ['Relight', 'Image Edit']
|
|
},
|
|
{
|
|
name: 'no-tags',
|
|
title: 'No Tags',
|
|
mediaType: 'image',
|
|
mediaSubtype: 'webp',
|
|
description: 'A template without tags.'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
await route.fulfill({
|
|
status: 200,
|
|
body: JSON.stringify(response),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store'
|
|
}
|
|
})
|
|
})
|
|
|
|
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
path: 'browser_tests/assets/example.webp',
|
|
headers: {
|
|
'Content-Type': 'image/webp',
|
|
'Cache-Control': 'no-store'
|
|
}
|
|
})
|
|
})
|
|
|
|
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
|
await expect(comfyPage.templates.content).toBeVisible()
|
|
|
|
const taggedCard = comfyPage.page.getByTestId(
|
|
TestIds.templates.workflowCard('tagged-template')
|
|
)
|
|
await expect(taggedCard).toBeVisible()
|
|
await expect(taggedCard.getByText('Relight')).toBeVisible()
|
|
await expect(taggedCard.getByText('Image Edit')).toBeVisible()
|
|
|
|
const templateGrid = comfyPage.page.getByTestId(TestIds.templates.content)
|
|
await expect(templateGrid).toHaveScreenshot(
|
|
'template-cards-with-overlay-tags.png'
|
|
)
|
|
}
|
|
)
|
|
|
|
test('Can open associated tutorial', async ({ comfyPage }) => {
|
|
const tutorialUrl = 'https://comfyanonymous.github.io/ComfyUI_examples/'
|
|
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
|
const response = [
|
|
{
|
|
moduleName: 'default',
|
|
title: 'Test Templates',
|
|
type: 'image',
|
|
templates: [
|
|
{
|
|
name: 'template-with-tutorial',
|
|
title: 'Template with a tutorial',
|
|
mediaType: 'audio',
|
|
mediaSubtype: 'wav',
|
|
description: 'This template has a tutorial',
|
|
tutorialUrl
|
|
}
|
|
]
|
|
}
|
|
]
|
|
await route.fulfill({
|
|
status: 200,
|
|
body: JSON.stringify(response),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store'
|
|
}
|
|
})
|
|
})
|
|
|
|
await comfyPage.page.route('**/templates/**.wav', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
body: getWav(),
|
|
headers: {
|
|
'Content-Type': 'image/x-wav',
|
|
'Cache-Control': 'no-store'
|
|
}
|
|
})
|
|
})
|
|
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
|
const card = comfyPage.page.getByTestId(
|
|
'template-workflow-template-with-tutorial'
|
|
)
|
|
await card.hover()
|
|
const tutorialButton = card.getByRole('button', { name: 'See a tutorial' })
|
|
await expect(tutorialButton).toBeVisible()
|
|
const popupPromise = comfyPage.page.waitForEvent('popup', { timeout: 0 })
|
|
await tutorialButton.click()
|
|
const popup = await popupPromise
|
|
expect(popup.url()).toEqual(tutorialUrl)
|
|
})
|
|
})
|