refactor(browser-tests): centralize template mocking via TemplateHelper (#11837)

## Summary

Centralize template route mocking by mirroring the existing
`AssetHelper` fixture pattern, so tests no longer hand-roll
`page.route('**/templates/index.json', ...)` and
`page.route('**/templates/**.webp', ...)` blocks.

## Changes

- **What**:
- Expand `browser_tests/fixtures/data/templateFixtures.ts` with stable
distribution exports (`STABLE_CLOUD_TEMPLATE`,
`STABLE_DESKTOP_TEMPLATE`, `STABLE_LOCAL_TEMPLATE`,
`STABLE_UNRESTRICTED_TEMPLATE`), an `ALL_DISTRIBUTION_TEMPLATES` set,
and a `generateTemplates(count, distribution?)` bulk generator.
`makeTemplate` and `mockTemplateIndex` are preserved.
- Add `browser_tests/fixtures/helpers/TemplateHelper.ts` modeled on
`AssetHelper`: an immutable `TemplateConfig` driven by composable
operators (`withTemplates`, `withTemplate`, `withCloudTemplates`,
`withDesktopTemplates`, `withLocalTemplates`,
`withUnrestrictedTemplates`, `withRawIndex`), a `TemplateHelper` class
with `mock()` / `mockIndex()` / `mockThumbnails()` / `clearMocks()`, and
a `createTemplateHelper(page, ...operators)` factory.
- Add `browser_tests/fixtures/templateApiFixture.ts` exposing
`templateApi: TemplateHelper` as a Playwright fixture with automatic
`clearMocks()` teardown (mirrors `assetApiFixture`).
- Migrate `browser_tests/tests/templateFilteringCount.spec.ts` to
`mergeTests(comfyPageFixture, templateApiFixture)` and
`templateApi.configure(withTemplates(...))` + `templateApi.mockIndex()`.
The webp thumbnail mock moves into the helper.
- **Breaking**: None. `makeTemplate` and `mockTemplateIndex` exports
remain.

## Review Focus

- `TemplateHelper.mock()` is split into `mockIndex()` and
`mockThumbnails()` so the spec can install the thumbnail handler in
`beforeEach` (where it has no per-test data) and the index handler
per-test (after `configure(...)`). Both still register through
`routeHandlers` so `clearMocks()` unroutes everything.
- Operators are pure (`(config) => config`) and the helper deep-copies
the resulting `templates` array, matching the AssetHelper immutability
pattern.
- The thumbnail route is `**/templates/**.webp` and serves
`browser_tests/assets/example.webp` — identical to the previous inline
behavior.

Fixes #11431

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11837-refactor-browser-tests-centralize-template-mocking-via-TemplateHelper-3546d73d365081beac00d80c2ab5ea1c)
by [Unito](https://www.unito.io)
This commit is contained in:
Christian Byrne
2026-05-14 15:54:40 -07:00
committed by GitHub
parent f429e1e0c4
commit e35bea51d6
4 changed files with 376 additions and 171 deletions

View File

@@ -2,6 +2,11 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
const Local = TemplateIncludeOnDistributionEnum.Local
export function makeTemplate(
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
}
]
}
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
name: 'cloud-stable',
title: 'Cloud Stable',
includeOnDistributions: [Cloud]
})
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
name: 'desktop-stable',
title: 'Desktop Stable',
includeOnDistributions: [Desktop]
})
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
name: 'local-stable',
title: 'Local Stable',
includeOnDistributions: [Local]
})
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
name: 'unrestricted-stable',
title: 'Unrestricted Stable'
})
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
STABLE_CLOUD_TEMPLATE,
STABLE_DESKTOP_TEMPLATE,
STABLE_LOCAL_TEMPLATE,
STABLE_UNRESTRICTED_TEMPLATE
]

View File

@@ -0,0 +1,198 @@
import type { Page, Route } from '@playwright/test'
import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
/**
* Generate N deterministic templates, optionally restricted to a distribution.
*
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
* for static test data with no executable fixture logic.
*/
function generateTemplates(
count: number,
distribution?: TemplateIncludeOnDistributionEnum
): TemplateInfo[] {
const slug = distribution ?? 'unrestricted'
return Array.from({ length: count }, (_, i) =>
makeTemplate({
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
title: `Generated ${slug} ${i + 1}`,
...(distribution ? { includeOnDistributions: [distribution] } : {})
})
)
}
export interface TemplateConfig {
readonly templates: readonly TemplateInfo[]
readonly index: readonly WorkflowTemplates[] | null
}
function emptyConfig(): TemplateConfig {
return { templates: [], index: null }
}
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
return templates.map((t) => structuredClone(t))
}
function cloneIndex(
index: readonly WorkflowTemplates[] | null
): WorkflowTemplates[] | null {
return index ? index.map((m) => structuredClone(m)) : null
}
function addTemplates(
config: TemplateConfig,
templates: TemplateInfo[]
): TemplateConfig {
return { ...config, templates: [...config.templates, ...templates] }
}
export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
return (config) => addTemplates(config, templates)
}
export function withTemplate(template: TemplateInfo): TemplateOperator {
return (config) => addTemplates(config, [template])
}
export function withCloudTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
)
}
export function withDesktopTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
)
}
export function withLocalTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
)
}
export function withUnrestrictedTemplates(count: number): TemplateOperator {
return (config) => addTemplates(config, generateTemplates(count))
}
/**
* Override the index payload entirely. Useful when a test needs a custom
* `WorkflowTemplates[]` shape (e.g. multiple modules).
*/
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
return (config) => ({ ...config, index })
}
export class TemplateHelper {
private templates: TemplateInfo[]
private index: WorkflowTemplates[] | null
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(
private readonly page: Page,
config: TemplateConfig = emptyConfig()
) {
this.templates = cloneTemplates(config.templates)
this.index = cloneIndex(config.index)
}
configure(...operators: TemplateOperator[]): void {
const config = operators.reduce<TemplateConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
this.templates = cloneTemplates(config.templates)
this.index = cloneIndex(config.index)
}
async mock(): Promise<void> {
await this.mockIndex()
await this.mockThumbnails()
}
async mockIndex(): Promise<void> {
const indexHandler = async (route: Route) => {
const payload = this.index ?? mockTemplateIndex(this.templates)
await route.fulfill({
status: 200,
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
const indexPattern = '**/templates/index.json'
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
await this.page.route(indexPattern, indexHandler)
}
async mockThumbnails(): Promise<void> {
const thumbnailHandler = async (route: Route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
}
const thumbnailPattern = '**/templates/**.webp'
this.routeHandlers.push({
pattern: thumbnailPattern,
handler: thumbnailHandler
})
await this.page.route(thumbnailPattern, thumbnailHandler)
}
getTemplates(): TemplateInfo[] {
return cloneTemplates(this.templates)
}
get templateCount(): number {
return this.templates.length
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
this.templates = []
this.index = null
}
}
export function createTemplateHelper(
page: Page,
...operators: TemplateOperator[]
): TemplateHelper {
const config = operators.reduce<TemplateConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
return new TemplateHelper(page, config)
}

View File

@@ -0,0 +1,16 @@
import { test as base } from '@playwright/test'
import type { TemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
import { createTemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
export const templateApiFixture = base.extend<{
templateApi: TemplateHelper
}>({
templateApi: async ({ page }, use) => {
const templateApi = createTemplateHelper(page)
await use(templateApi)
await templateApi.clearMocks()
}
})

View File

@@ -1,13 +1,13 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
const test = mergeTests(comfyPageFixture, templateApiFixture)
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
@@ -17,7 +17,7 @@ test.describe(
'Template distribution filtering count',
{ tag: '@cloud' },
() => {
test.beforeEach(async ({ comfyPage }) => {
test.beforeEach(async ({ comfyPage, templateApi }) => {
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
await comfyPage.settings.setSetting(
'Comfy.Templates.SelectedUseCases',
@@ -26,53 +26,37 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
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 templateApi.mockThumbnails()
})
test('displayed count matches visible cards when distribution filter excludes templates', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-1',
title: 'Cloud One',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'cloud-2',
title: 'Cloud Two',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-hidden',
title: 'Desktop Hidden',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'universal',
title: 'Universal'
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'cloud-1',
title: 'Cloud One',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'cloud-2',
title: 'Cloud Two',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-hidden',
title: 'Desktop Hidden',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'universal',
title: 'Universal'
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -86,45 +70,38 @@ test.describe(
})
test('filtered count reflects distribution + model filter together', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud-1',
title: 'Wan Cloud 1',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-cloud-2',
title: 'Wan Cloud 2',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'wan-cloud-1',
title: 'Wan Cloud 1',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-cloud-2',
title: 'Wan Cloud 2',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -144,36 +121,29 @@ test.describe(
})
test('desktop-only templates never leak into DOM on cloud distribution', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-visible',
title: 'Cloud Visible',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-leak-check',
title: 'Desktop Leak Check',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'local-leak-check',
title: 'Local Leak Check',
includeOnDistributions: [Local]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'cloud-visible',
title: 'Cloud Visible',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-leak-check',
title: 'Desktop Leak Check',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'local-leak-check',
title: 'Local Leak Check',
includeOnDistributions: [Local]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -200,28 +170,21 @@ test.describe(
})
test('templates without includeOnDistributions are visible on cloud', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
makeTemplate({
name: 'cloud-only',
title: 'Cloud Only',
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
makeTemplate({
name: 'cloud-only',
title: 'Cloud Only',
includeOnDistributions: [Cloud]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -234,39 +197,32 @@ test.describe(
})
test('clear filters button resets to correct distribution-filtered total', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud',
title: 'Wan Cloud',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'wan-cloud',
title: 'Wan Cloud',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()