mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
## Motivation Browser tests mock API responses with `route.fulfill()` using untyped inline JSON. When the OpenAPI spec changes, these mocks silently drift — mismatches aren't caught at compile time and only surface as test failures at runtime. We already have auto-generated types from OpenAPI and manual Zod schemas. This PR makes those types the source of truth for test mock data. From Mar 27 PR review session action item: "instruct agents to use schemas and types when writing browser tests." ## Type packages and their API coverage The frontend has two OpenAPI-generated type packages, each targeting a different backend API with a different code generation tool: | Package | Target API | Generator | TS types | Zod schemas | |---------|-----------|-----------|----------|-------------| | `@comfyorg/registry-types` | Registry API (node packages, releases, subscriptions, customers) | `openapi-typescript` | Yes | **No** | | `@comfyorg/ingest-types` | Ingest API (hub workflows, asset uploads, workspaces) | `@hey-api/openapi-ts` | Yes | Yes | Additionally, Python backend endpoints (`/api/queue`, `/api/features`, `/api/settings`, etc.) are typed via manual Zod schemas in `src/schemas/apiSchema.ts`. This PR applies **compile-time type checking** using these existing types. Runtime validation via Zod `.parse()` is not yet possible for all endpoints because `registry-types` does not generate Zod schemas — this requires a separate migration of `registry-types` to `@hey-api/openapi-ts` (#10674). ## Summary - Add "Typed API Mocks" guideline to `docs/guidance/playwright.md` with a sources-of-truth table mapping endpoint categories to their type packages - Add rule to `AGENTS.md` Playwright section requiring typed mock data - Refactor `releaseNotifications.spec.ts` to use `ReleaseNote` type (from `registry-types`) via `createMockRelease()` factory - Annotate template mock in `templates.spec.ts` with `WorkflowTemplates[]` type Refs #10656 ## Example workflow: writing a new typed E2E test mock When adding a new `route.fulfill()` mock, follow these steps: ### 1. Identify the type source Check which API the endpoint belongs to: | Endpoint category | Type source | Zod available | |---|---|---| | Ingest API (hub, billing, workflows) | `@comfyorg/ingest-types` | Yes — use `.parse()` | | Registry API (releases, nodes, publishers) | `@comfyorg/registry-types` | Not yet (#10674) — TS type only | | Python backend (queue, history, settings) | `src/schemas/apiSchema.ts` | Yes — use `z.infer` | | Templates | `src/platform/workflow/templates/types/template.ts` | No — TS type only | ### 2. Create a typed factory (with Zod when available) **Ingest API endpoints** — Zod schemas exist, use `.parse()` for runtime validation: ```typescript import { zBillingStatusResponse } from '@comfyorg/ingest-types/zod' import type { BillingStatusResponse } from '@comfyorg/ingest-types' function createMockBillingStatus( overrides?: Partial<BillingStatusResponse> ): BillingStatusResponse { return zBillingStatusResponse.parse({ plan: 'free', credits_remaining: 100, renewal_date: '2026-04-28T00:00:00Z', ...overrides }) } ``` **Registry API endpoints** — TS type only (Zod not yet generated): ```typescript import type { ReleaseNote } from '../../src/platform/updates/common/releaseService' function createMockRelease( overrides?: Partial<ReleaseNote> ): ReleaseNote { return { id: 1, project: 'comfyui', version: 'v0.3.44', attention: 'medium', content: '## New Features', published_at: new Date().toISOString(), ...overrides } } ``` ### 3. Use in test ```typescript test('should show upgrade banner for free plan', async ({ comfyPage }) => { await comfyPage.page.route('**/billing/status', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(createMockBillingStatus({ plan: 'free' })) }) }) await comfyPage.setup() await expect(comfyPage.page.getByText('Upgrade')).toBeVisible() }) ``` The factory pattern keeps test bodies focused on **what varies** (the override) rather than the full response shape. ## Scope decisions | File | Decision | Reason | |------|----------|--------| | `releaseNotifications.spec.ts` | Typed | `ReleaseNote` type available from `registry-types` | | `templates.spec.ts` | Typed | `WorkflowTemplates` type available in `src/platform/workflow/templates/types/` | | `QueueHelper.ts` | Skipped | Dead code — instantiated but never called in any test | | `FeatureFlagHelper.ts` | Skipped | Response type is inherently `Record<string, unknown>`, no stronger type exists | | Fixture factories | Deferred | Coordinate with Ben's fixture restructuring work to avoid duplication | ## Follow-up work Sub-issues of #10656: - #10670 — Clean up dead `QueueHelper` or rewrite against `/api/jobs` endpoint - #10671 — Expand typed factory pattern to more endpoints - #10672 — Evaluate OpenAPI generation for excluded Python backend endpoints - #10674 — Migrate `registry-types` from `openapi-typescript` to `@hey-api/openapi-ts` to enable Zod schema generation ## Test plan - [x] `pnpm typecheck:browser` passes - [x] `pnpm lint` passes - [ ] Existing `releaseNotifications` and `templates` tests pass in CI
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import type { Page } from '@playwright/test'
|
|
import { expect } from '@playwright/test'
|
|
|
|
import type { WorkflowTemplates } from '../../src/platform/workflow/templates/types/template'
|
|
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
|
import { TestIds } from '../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.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
|
})
|
|
|
|
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
|
|
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(), { timeout: 250 })
|
|
.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(), { timeout: 250 })
|
|
.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({ timeout: 5000 })
|
|
})
|
|
|
|
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.page.getByRole('dialog').filter({
|
|
has: comfyPage.page.getByRole('heading', { name: 'Modèles', exact: true })
|
|
})
|
|
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
|
|
}) => {
|
|
// Set locale to a language that doesn't have a template file
|
|
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
|
|
|
|
// Wait for the German request (expected to 404)
|
|
const germanRequestPromise = comfyPage.page.waitForRequest(
|
|
'**/templates/index.de.json'
|
|
)
|
|
|
|
// Wait for the fallback English request
|
|
const englishRequestPromise = comfyPage.page.waitForRequest(
|
|
'**/templates/index.json'
|
|
)
|
|
|
|
// Intercept the German file to simulate a 404
|
|
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
|
|
await route.fulfill({
|
|
status: 404,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
body: 'Not Found'
|
|
})
|
|
})
|
|
|
|
// Allow the English index to load normally
|
|
await comfyPage.page.route('**/templates/index.json', (route) =>
|
|
route.continue()
|
|
)
|
|
|
|
// Load the templates dialog
|
|
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
|
await expect(comfyPage.templates.content).toBeVisible()
|
|
|
|
// Verify German was requested first, then English as fallback
|
|
const germanRequest = await germanRequestPromise
|
|
const englishRequest = await englishRequestPromise
|
|
|
|
expect(germanRequest.url()).toContain('templates/index.de.json')
|
|
expect(englishRequest.url()).toContain('templates/index.json')
|
|
|
|
// Verify English titles are shown as fallback
|
|
await expect(
|
|
comfyPage.page.getByRole('main').getByText('All Templates')
|
|
).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.locator(
|
|
'[data-testid="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 dialog = comfyPage.page.getByRole('dialog')
|
|
const sortBySelect = dialog.getByRole('combobox', { name: /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.locator(
|
|
'[data-testid="template-workflow-short-description"]'
|
|
)
|
|
).toBeVisible({ timeout: 5000 })
|
|
|
|
// Verify all three cards with different descriptions are visible
|
|
const shortDescCard = comfyPage.page.locator(
|
|
'[data-testid="template-workflow-short-description"]'
|
|
)
|
|
const mediumDescCard = comfyPage.page.locator(
|
|
'[data-testid="template-workflow-medium-description"]'
|
|
)
|
|
const longDescCard = comfyPage.page.locator(
|
|
'[data-testid="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.locator(
|
|
'[data-testid="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({ timeout: 5000 })
|
|
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'
|
|
)
|
|
}
|
|
)
|
|
})
|