Compare commits

...

33 Commits

Author SHA1 Message Date
Benjamin Lu
051039aa32 test: restrict playwright to browser specs 2026-04-13 19:51:56 -07:00
Benjamin Lu
66066c0dce test: separate browser helper tests from e2e specs 2026-04-13 19:16:02 -07:00
Benjamin Lu
b61174c0ea Merge branch 'main' into bl/assets-sidebar-test-foundation 2026-04-13 19:05:36 -07:00
Alexander Brown
90b24785b3 Merge branch 'main' into bl/assets-sidebar-test-foundation 2026-04-13 15:39:55 -07:00
Benjamin Lu
f234dd9ebb test: drop duplicate browser helper tests in scripts 2026-04-13 15:39:32 -07:00
Benjamin Lu
200d7328ef test: move browser helper tests out of scripts 2026-04-13 15:38:41 -07:00
Alexander Brown
ce3a76893e Merge branch 'main' into bl/assets-sidebar-test-foundation 2026-04-13 15:12:11 -07:00
Benjamin Lu
dcf2fbdcc2 fix: resolve PR #10630 merge conflicts 2026-04-13 13:32:40 -07:00
Alexander Brown
0a041e5e63 Merge branch 'main' into bl/assets-sidebar-test-foundation 2026-04-10 13:16:16 -07:00
Benjamin Lu
c4f468366c fix: remove redundant assets sidebar locators 2026-04-10 12:54:27 -07:00
Benjamin Lu
ff93926e5e fix: use readonly locators in assets sidebar fixture 2026-04-10 12:32:47 -07:00
Benjamin Lu
d67f385026 fix: resolve PR #10630 merge conflicts 2026-04-10 11:40:11 -07:00
Alexander Brown
2699a4d78f Merge branch 'main' into bl/assets-sidebar-test-foundation 2026-04-09 16:29:20 -07:00
Benjamin Lu
f7bd8e58af test: normalize @e2e imports and drop merge churn 2026-04-09 14:43:44 -07:00
GitHub Action
d651f562d7 [automated] Apply ESLint and Oxfmt fixes 2026-04-09 21:14:57 +00:00
Benjamin Lu
3a22391bd2 Merge origin/main into bl/assets-sidebar-test-foundation 2026-04-09 14:00:48 -07:00
Benjamin Lu
04a62fa86f test: name fixture timestamp fallback 2026-04-09 13:50:00 -07:00
Benjamin Lu
467c556da3 test: extract seeded asset file builder 2026-04-09 13:50:00 -07:00
Benjamin Lu
a721bdc67b test: bucket mock job outputs by media type 2026-04-09 13:50:00 -07:00
Benjamin Lu
728f3fb764 test: restore asset delete confirmation coverage 2026-04-09 13:50:00 -07:00
Benjamin Lu
40cfad5361 test: continue non-post history requests 2026-04-09 13:50:00 -07:00
Benjamin Lu
a8ebcfb6e9 test: cover in-memory jobs backend routes 2026-04-09 13:50:00 -07:00
Benjamin Lu
3ae1fd692c test: use toolbar semantics for asset selection footer 2026-04-09 13:50:00 -07:00
Benjamin Lu
22e318535c test: use ControlOrMeta in keyboard helper 2026-04-09 13:50:00 -07:00
Benjamin Lu
50de97488b test: use ControlOrMeta in asset bulk-select spec 2026-04-09 13:50:00 -07:00
Benjamin Lu
058057f042 test: use ControlOrMeta in asset selection helper 2026-04-09 13:49:59 -07:00
Benjamin Lu
d1ac778add Switch to @e2e
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-09 13:48:48 -07:00
Benjamin Lu
c2d1c59b09 Merge branch 'main' into bl/assets-sidebar-test-foundation 2026-04-07 18:25:21 -07:00
Benjamin Lu
c0c0f00c24 test: key asset scenario view mocks by location 2026-04-07 16:17:35 -07:00
Benjamin Lu
3654bd7e08 test: move asset helper fixture off ComfyPage 2026-04-07 14:33:52 -07:00
Benjamin Lu
fcb635511a test: fix queue overlay failed-job fixtures 2026-04-07 14:32:53 -07:00
Benjamin Lu
268a804b58 Remove explicit redundant timeouts 2026-04-07 14:32:53 -07:00
Benjamin Lu
920f4f8103 test: split assets sidebar browser fixtures 2026-04-07 14:32:53 -07:00
29 changed files with 1785 additions and 589 deletions

View File

@@ -22,7 +22,8 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
- Entry Point: `src/main.ts`.
- Tests:
- unit/component in `src/**/*.test.ts`
- E2E (Playwright) in `browser_tests/**/*.spec.ts`
- browser helper/unit tests in `browser_tests/**/*.test.ts`
- E2E (Playwright) in `browser_tests/tests/**/*.spec.ts`
- Public assets: `public/`
- Build output: `dist/`
- Configs

View File

@@ -108,6 +108,11 @@ Browser tests in this project follow a specific organization pattern:
- Organized by functionality (e.g., `widget.spec.ts`, `interaction.spec.ts`)
- Snapshot directories (e.g., `widget.spec.ts-snapshots/`) contain reference screenshots
- **Helper Unit Tests**: Colocated alongside browser-test helpers and fixtures
- Use `.test.ts`
- Run under Vitest, not Playwright
- Do not place them under `browser_tests/tests/`, which is reserved for Playwright `*.spec.ts`
- **Utilities**: Located in `utils/` - Common utility functions
- `litegraphUtils.ts` - Utilities for working with LiteGraph nodes

View File

@@ -29,9 +29,6 @@ import {
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
@@ -177,8 +174,6 @@ export class ComfyPage {
public readonly bottomPanel: BottomPanel
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper
public readonly visibleToasts: Locator
@@ -231,8 +226,6 @@ export class ComfyPage {
this.bottomPanel = new BottomPanel(page)
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
this.cloudAuth = new CloudAuthHelper(page)
}
@@ -461,7 +454,6 @@ export const comfyPageFixture = base.extend<{
await use(comfyPage)
await comfyPage.assetApi.clearMocks()
if (needsPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {

View File

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

View File

@@ -0,0 +1,15 @@
import { test as base } from '@playwright/test'
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
export const assetScenarioFixture = base.extend<{
assetScenario: AssetScenarioHelper
}>({
assetScenario: async ({ page }, use) => {
const assetScenario = new AssetScenarioHelper(page)
await use(assetScenario)
await assetScenario.clear()
}
})

View File

@@ -249,70 +249,62 @@ export class ModelLibrarySidebarTab extends SidebarTab {
}
export class AssetsSidebarTab extends SidebarTab {
// --- Tab navigation ---
public readonly root: Locator
public readonly generatedTab: Locator
public readonly importedTab: Locator
// --- Empty state ---
public readonly emptyStateMessage: Locator
// --- Search & filter ---
public readonly searchInput: Locator
public readonly settingsButton: Locator
// --- View mode ---
public readonly listViewOption: Locator
public readonly gridViewOption: Locator
// --- Sort options (cloud-only, shown inside settings popover) ---
public readonly backToAssetsButton: Locator
public readonly copyJobIdButton: Locator
public readonly previewDialog: Locator
public readonly sortNewestFirst: Locator
public readonly sortOldestFirst: Locator
// --- Asset cards ---
public readonly assetCards: Locator
public readonly selectedCards: Locator
// --- List view items ---
public readonly listViewItems: Locator
// --- Selection footer ---
public readonly selectionFooter: Locator
public readonly selectionCountButton: Locator
public readonly deselectAllButton: Locator
public readonly deleteSelectedButton: Locator
public readonly downloadSelectedButton: Locator
// --- Folder view ---
public readonly backToAssetsButton: Locator
// --- Loading ---
public readonly skeletonLoaders: Locator
constructor(public override readonly page: Page) {
super(page, 'assets')
this.root = page.locator('.sidebar-content-container')
this.generatedTab = page.getByRole('tab', { name: 'Generated' })
this.importedTab = page.getByRole('tab', { name: 'Imported' })
this.emptyStateMessage = page.getByText(
'Upload files or generate content to see them here'
)
this.searchInput = page.getByPlaceholder('Search Assets...')
this.settingsButton = page.getByRole('button', { name: 'View settings' })
this.searchInput = this.root.getByPlaceholder(/Search Assets/i)
this.settingsButton = this.root.getByLabel('View settings')
this.listViewOption = page.getByText('List view')
this.gridViewOption = page.getByText('Grid view')
this.backToAssetsButton = page.getByRole('button', {
name: 'Back to all assets'
})
this.copyJobIdButton = page.getByRole('button', {
name: 'Copy job ID'
})
this.previewDialog = page.getByRole('dialog', { name: 'Gallery' })
this.sortNewestFirst = page.getByText('Newest first')
this.sortOldestFirst = page.getByText('Oldest first')
this.assetCards = page
this.assetCards = this.root
.getByRole('button')
.and(page.locator('[data-selected]'))
this.selectedCards = page.locator('[data-selected="true"]')
this.listViewItems = page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'
)
this.selectionFooter = page
.locator('.sidebar-content-container')
.locator('..')
.locator('[class*="h-18"]')
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
.and(this.root.locator('[data-selected]'))
this.selectedCards = this.root.locator('[data-selected="true"]')
this.listViewItems = this.root.getByRole('button', { name: /asset$/i })
this.selectionFooter = this.root.locator('..').getByRole('toolbar', {
name: 'Selected asset actions'
})
this.selectionCountButton = this.root
.getByRole('button', { name: /Assets Selected:/ })
.or(page.getByText(/Assets Selected: \d+/))
.first()
this.deselectAllButton = page.getByText('Deselect all')
this.deleteSelectedButton = page
.getByTestId('assets-delete-selected')
@@ -322,28 +314,113 @@ export class AssetsSidebarTab extends SidebarTab {
.getByTestId('assets-download-selected')
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
this.backToAssetsButton = page.getByText('Back to all assets')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'
)
this.skeletonLoaders = this.root.locator('.animate-pulse')
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
previewImage(filename: string) {
return this.previewDialog.getByRole('img', { name: filename })
}
asset(name: string) {
return this.getAssetCardByName(name)
}
getAssetCardByName(name: string) {
return this.assetCards.filter({ hasText: name })
return this.assetCards.filter({ hasText: name }).first()
}
contextMenuItem(label: string) {
return this.page.locator('.p-contextmenu').getByText(label)
}
contextMenuAction(label: string) {
return this.contextMenuItem(label)
}
async showGenerated() {
await this.switchToGenerated()
}
async showImported() {
await this.switchToImported()
}
async search(query: string) {
await this.searchInput.fill(query)
}
async switchToListView() {
await this.openSettingsMenu()
await this.listViewOption.click()
}
async switchToGridView() {
await this.openSettingsMenu()
await this.gridViewOption.click()
}
async openContextMenuForAsset(name: string) {
await this.asset(name).click({ button: 'right' })
await this.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
}
async runContextMenuAction(assetName: string, actionName: string) {
await this.openContextMenuForAsset(assetName)
await this.contextMenuAction(actionName).click()
}
async openAssetPreview(name: string) {
const asset = this.asset(name)
await asset.hover()
const zoomButton = asset.getByLabel('Zoom in')
if (await zoomButton.isVisible().catch(() => false)) {
await zoomButton.click()
return
}
await asset.dblclick()
}
async openOutputFolder(name: string) {
await this.asset(name)
.getByRole('button', { name: 'See more outputs' })
.click()
await this.backToAssetsButton.waitFor({ state: 'visible' })
}
async toggleStack(name: string) {
await this.asset(name)
.getByRole('button', { name: 'See more outputs' })
.click()
}
async selectAssets(names: string[]) {
if (names.length === 0) {
return
}
await this.asset(names[0]).click()
for (const name of names.slice(1)) {
await this.asset(name).click({
modifiers: ['ControlOrMeta']
})
}
}
override async open() {
// Remove any toast notifications that may overlay the sidebar button
await this.dismissToasts()
await super.open()
await this.root.waitFor({ state: 'visible' })
await this.generatedTab.waitFor({ state: 'visible' })
}

View File

@@ -0,0 +1,133 @@
import type { Page, Route } from '@playwright/test'
import { describe, expect, it, vi } from 'vitest'
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
type RouteHandler = (route: Route) => Promise<void>
type RegisteredRoute = {
pattern: string | RegExp
handler: RouteHandler
}
type PageStub = Pick<Page, 'route' | 'unroute'>
type FulfillOptions = NonNullable<Parameters<Route['fulfill']>[0]>
function createPageStub(): {
page: PageStub
routes: RegisteredRoute[]
} {
const routes: RegisteredRoute[] = []
const page = {
route: vi.fn(async (pattern: string | RegExp, handler: RouteHandler) => {
routes.push({ pattern, handler })
}),
unroute: vi.fn(async () => {})
} satisfies PageStub
return { page, routes }
}
function getRouteHandler(
routes: RegisteredRoute[],
matcher: (pattern: string | RegExp) => boolean
): RouteHandler {
const registeredRoute = routes.find(({ pattern }) => matcher(pattern))
if (!registeredRoute) {
throw new Error('Expected route handler to be registered')
}
return registeredRoute.handler
}
function createRouteInvocation(url: string): {
route: Route
getFulfilled: () => FulfillOptions | undefined
} {
let fulfilled: FulfillOptions | undefined
const route = {
request: () =>
({
url: () => url
}) as ReturnType<Route['request']>,
fulfill: vi.fn(async (options?: FulfillOptions) => {
if (!options) {
throw new Error('Expected route to be fulfilled with options')
}
fulfilled = options
})
} satisfies Pick<Route, 'request' | 'fulfill'>
return {
route: route as unknown as Route,
getFulfilled: () => fulfilled
}
}
function bodyToText(body: FulfillOptions['body']): string {
if (body instanceof Uint8Array) {
return Buffer.from(body).toString('utf-8')
}
return `${body ?? ''}`
}
async function invokeViewRoute(
handler: RouteHandler,
url: string
): Promise<string> {
const invocation = createRouteInvocation(url)
await handler(invocation.route)
const fulfilled = invocation.getFulfilled()
expect(fulfilled).toBeDefined()
return bodyToText(fulfilled?.body)
}
describe('AssetScenarioHelper', () => {
it('serves generated outputs and imported files through the view route', async () => {
const { page, routes } = createPageStub()
const helper = new AssetScenarioHelper(page as unknown as Page)
await helper.seedGeneratedHistory([
createMockJob({
id: 'job-generated',
preview_output: {
filename: 'generated.json',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
})
])
await helper.seedImportedFiles(['imported.txt'])
const viewRouteHandler = getRouteHandler(
routes,
(pattern) =>
pattern instanceof RegExp && /api\\\/view/.test(pattern.source)
)
await expect(
invokeViewRoute(
viewRouteHandler,
'http://localhost/api/view?filename=generated.json&type=output&subfolder='
)
).resolves.toBe(JSON.stringify({ mocked: true }, null, 2))
await expect(
invokeViewRoute(
viewRouteHandler,
'http://localhost/api/view?filename=imported.txt&type=input&subfolder='
)
).resolves.toBe('mocked asset content')
})
})

View File

@@ -0,0 +1,275 @@
import { readFile } from 'node:fs/promises'
import type { Page, Route } from '@playwright/test'
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import { buildMockJobOutputs } from '@e2e/fixtures/helpers/buildMockJobOutputs'
import type {
GeneratedJobFixture,
GeneratedOutputFixture,
ImportedAssetFixture
} from '@e2e/fixtures/helpers/assetScenarioTypes'
import { InMemoryJobsBackend } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import {
buildSeededFileKey,
buildSeededFiles,
defaultFileFor
} from '@e2e/fixtures/helpers/seededAssetFiles'
import type { SeededAssetFile } from '@e2e/fixtures/helpers/seededAssetFiles'
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
const DEFAULT_FIXTURE_CREATE_TIME = Date.UTC(2024, 0, 1, 0, 0, 0)
type MockPreviewOutput = NonNullable<JobEntry['preview_output']> & {
filename?: string
subfolder?: string
type?: GeneratedOutputFixture['type']
nodeId: string
mediaType?: string
display_name?: string
}
function normalizeOutputFixture(
output: GeneratedOutputFixture
): GeneratedOutputFixture {
const fallback = defaultFileFor(output.filename)
return {
mediaType: 'images',
subfolder: '',
type: 'output',
...output,
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType
}
}
function createOutputFilename(baseFilename: string, index: number): string {
if (index === 0) {
return baseFilename
}
const extensionIndex = baseFilename.lastIndexOf('.')
if (extensionIndex === -1) {
return `${baseFilename}-${index + 1}`
}
return `${baseFilename.slice(0, extensionIndex)}-${index + 1}${baseFilename.slice(extensionIndex)}`
}
function getPreviewOutput(
previewOutput: JobEntry['preview_output'] | undefined
): MockPreviewOutput | undefined {
return previewOutput as MockPreviewOutput | undefined
}
function outputsFromJobEntry(
job: JobEntry
): [GeneratedOutputFixture, ...GeneratedOutputFixture[]] {
const previewOutput = getPreviewOutput(job.preview_output)
const outputCount = Math.max(job.outputs_count ?? 1, 1)
const baseFilename = previewOutput?.filename ?? `output_${job.id}.png`
const mediaType: GeneratedOutputFixture['mediaType'] =
previewOutput?.mediaType === 'video' || previewOutput?.mediaType === 'audio'
? previewOutput.mediaType
: 'images'
const outputs = Array.from({ length: outputCount }, (_, index) => ({
filename: createOutputFilename(baseFilename, index),
displayName: index === 0 ? previewOutput?.display_name : undefined,
mediaType,
subfolder: previewOutput?.subfolder ?? '',
type: previewOutput?.type ?? 'output'
}))
return [outputs[0], ...outputs.slice(1)]
}
function generatedJobFromJobEntry(job: JobEntry): GeneratedJobFixture {
return {
jobId: job.id,
status: job.status,
outputs: outputsFromJobEntry(job),
createTime: job.create_time,
executionStartTime: job.execution_start_time,
executionEndTime: job.execution_end_time,
workflowId: job.workflow_id
}
}
function buildSeededJob(job: GeneratedJobFixture) {
const outputs = job.outputs.map(normalizeOutputFixture)
const preview = outputs[0]
const createTime =
job.createTime ??
(job.createdAt
? new Date(job.createdAt).getTime()
: DEFAULT_FIXTURE_CREATE_TIME)
const executionStartTime = job.executionStartTime ?? createTime
const executionEndTime = job.executionEndTime ?? createTime + 2_000
const listItem: JobEntry = {
id: job.jobId,
status: job.status ?? 'completed',
create_time: createTime,
execution_start_time: executionStartTime,
execution_end_time: executionEndTime,
preview_output: {
filename: preview.filename,
subfolder: preview.subfolder ?? '',
type: preview.type ?? 'output',
nodeId: job.nodeId ?? '5',
mediaType: preview.mediaType ?? 'images',
display_name: preview.displayName
},
outputs_count: outputs.length,
...(job.workflowId ? { workflow_id: job.workflowId } : {})
}
const detail: JobDetailResponse = {
...listItem,
workflow: job.workflow,
outputs: buildMockJobOutputs(job, outputs),
update_time: executionEndTime
}
return { listItem, detail }
}
export class AssetScenarioHelper {
private readonly jobsBackend: InMemoryJobsBackend
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
private generatedJobs: GeneratedJobFixture[] = []
private importedFiles: ImportedAssetFixture[] = []
private seededFiles = new Map<string, SeededAssetFile>()
constructor(private readonly page: Page) {
this.jobsBackend = new InMemoryJobsBackend(page)
}
async seedGeneratedHistory(jobs: readonly JobEntry[]): Promise<void> {
await this.seed({
generated: jobs.map(generatedJobFromJobEntry),
imported: this.importedFiles
})
}
async seedImportedFiles(files: readonly string[]): Promise<void> {
await this.seed({
generated: this.generatedJobs,
imported: files.map((name) => ({ name }))
})
}
async seedEmptyState(): Promise<void> {
await this.seed({ generated: [], imported: [] })
}
async clear(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
this.seededFiles.clear()
await this.jobsBackend.clear()
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
if (this.viewRouteHandler) {
await this.page.unroute(viewRoutePattern, this.viewRouteHandler)
this.viewRouteHandler = null
}
}
private async seed({
generated,
imported
}: {
generated: GeneratedJobFixture[]
imported: ImportedAssetFixture[]
}): Promise<void> {
this.generatedJobs = [...generated]
this.importedFiles = [...imported]
this.seededFiles = buildSeededFiles({
generated: this.generatedJobs,
imported: this.importedFiles
})
await this.jobsBackend.seed(this.generatedJobs.map(buildSeededJob))
await this.ensureInputFilesRoute()
await this.ensureViewRoute()
}
private async ensureInputFilesRoute(): Promise<void> {
if (this.inputFilesRouteHandler) {
return
}
this.inputFilesRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles.map((asset) => asset.name))
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
private async ensureViewRoute(): Promise<void> {
if (this.viewRouteHandler) {
return
}
this.viewRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename')
const type = url.searchParams.get('type') ?? 'output'
const subfolder = url.searchParams.get('subfolder') ?? ''
if (!filename) {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'Missing filename' })
})
return
}
const seededFile =
this.seededFiles.get(
buildSeededFileKey({
filename,
type,
subfolder
})
) ?? defaultFileFor(filename)
if (seededFile.filePath) {
const body = await readFile(seededFile.filePath)
await route.fulfill({
status: 200,
contentType: seededFile.contentType ?? getMimeType(filename),
body
})
return
}
await route.fulfill({
status: 200,
contentType: seededFile.contentType ?? getMimeType(filename),
body: seededFile.textContent ?? ''
})
}
await this.page.route(viewRoutePattern, this.viewRouteHandler)
}
}

View File

@@ -1,251 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const historyRoutePattern = /\/api\/history$/
/** Factory to create a mock completed job with preview output. */
export function createMockJob(
overrides: Partial<RawJobListItem> & { id: string }
): RawJobListItem {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
priority: 0,
...overrides
}
}
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
export function createMockJobs(
count: number,
baseOverrides?: Partial<RawJobListItem>
): RawJobListItem[] {
const now = Date.now()
return Array.from({ length: count }, (_, i) =>
createMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: now - i * 60_000,
execution_start_time: now - i * 60_000,
execution_end_time: now - i * 60_000 + 5000 + i * 1000,
preview_output: {
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
...baseOverrides
})
)
}
/** Create mock imported file names with various media types. */
export function createMockImportedFiles(count: number): string[] {
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
return Array.from(
{ length: count },
(_, i) =>
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
)
}
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
function getExecutionDuration(job: RawJobListItem): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private importedFiles: string[] = []
constructor(private readonly page: Page) {}
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
this.generatedJobs = [...jobs]
if (this.jobsRouteHandler) {
return
}
this.jobsRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
let filteredJobs = [...this.generatedJobs]
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
if (this.inputFilesRouteHandler) {
return
}
this.inputFilesRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles)
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
/**
* Mock the POST /api/history endpoint used for deleting history items.
* On receiving a `{ delete: [id] }` payload, removes matching jobs from
* the in-memory mock state so subsequent /api/jobs fetches reflect the
* deletion.
*/
async mockDeleteHistory(): Promise<void> {
if (this.deleteHistoryRouteHandler) return
this.deleteHistoryRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const body = request.postDataJSON() as { delete?: string[] }
if (body.delete) {
const idsToRemove = new Set(body.delete)
this.generatedJobs = this.generatedJobs.filter(
(job) => !idsToRemove.has(job.id)
)
}
await route.fulfill({ status: 200, body: '{}' })
}
await this.page.route(historyRoutePattern, this.deleteHistoryRouteHandler)
}
async mockEmptyState(): Promise<void> {
await this.mockOutputHistory([])
await this.mockInputFiles([])
}
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
if (this.jobsRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
this.jobsRouteHandler = null
}
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
if (this.deleteHistoryRouteHandler) {
await this.page.unroute(
historyRoutePattern,
this.deleteHistoryRouteHandler
)
this.deleteHistoryRouteHandler = null
}
}
}

View File

@@ -1,18 +1,19 @@
import type { WebSocketRoute } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
/**
* Helper for simulating prompt execution in e2e tests.
*/
export class ExecutionHelper {
private jobCounter = 0
private readonly completedJobs: RawJobListItem[] = []
private readonly completedJobs: JobEntry[] = []
private readonly page: ComfyPage['page']
private readonly command: ComfyPage['command']
private readonly assets: ComfyPage['assets']
private readonly assetScenario: AssetScenarioHelper
constructor(
comfyPage: ComfyPage,
@@ -20,7 +21,7 @@ export class ExecutionHelper {
) {
this.page = comfyPage.page
this.command = comfyPage.command
this.assets = comfyPage.assets
this.assetScenario = new AssetScenarioHelper(comfyPage.page)
}
/**
@@ -172,8 +173,6 @@ export class ExecutionHelper {
/**
* Complete a job by adding it to mock history, sending execution_success,
* and triggering a history refresh via a status event.
*
* Requires an {@link AssetsHelper} to be passed in the constructor.
*/
async completeWithHistory(
jobId: string,
@@ -193,7 +192,7 @@ export class ExecutionHelper {
})
)
await this.assets.mockOutputHistory(this.completedJobs)
await this.assetScenario.seedGeneratedHistory(this.completedJobs)
this.executionSuccess(jobId)
// Trigger queue/history refresh
this.status(0)

View File

@@ -0,0 +1,398 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
import { describe, expect, it, vi } from 'vitest'
import { InMemoryJobsBackend } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
import type { SeededJob } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
type RouteHandler = (route: Route) => Promise<void>
type RegisteredRoute = {
pattern: string | RegExp
handler: RouteHandler
}
type PageStub = Pick<Page, 'route' | 'unroute'>
type FulfillOptions = NonNullable<Parameters<Route['fulfill']>[0]>
function createPageStub(): {
page: PageStub
routes: RegisteredRoute[]
} {
const routes: RegisteredRoute[] = []
const page = {
route: vi.fn(async (pattern: string | RegExp, handler: RouteHandler) => {
routes.push({ pattern, handler })
}),
unroute: vi.fn(async () => {})
} satisfies PageStub
return { page, routes }
}
function createSeededJob({
id,
status = 'completed',
createTime,
executionStartTime = createTime,
executionEndTime = createTime + 1_000,
workflowId
}: {
id: string
status?: JobEntry['status']
createTime: number
executionStartTime?: number
executionEndTime?: number
workflowId?: string
}): SeededJob {
const previewOutput = { filename: `${id}.png` }
const terminalState =
status === 'completed' || status === 'failed' || status === 'cancelled'
const listItem: JobEntry = {
id,
status,
create_time: createTime,
...(workflowId ? { workflow_id: workflowId } : {}),
...(terminalState
? {
preview_output: previewOutput,
outputs_count: 1,
execution_start_time: executionStartTime,
execution_end_time: executionEndTime
}
: {})
}
const detail: JobDetailResponse = {
id,
status,
create_time: createTime,
update_time: executionEndTime,
...(workflowId ? { workflow_id: workflowId } : {}),
...(terminalState
? {
preview_output: previewOutput,
outputs_count: 1,
outputs: {}
}
: {})
}
return { listItem, detail }
}
function getRouteHandler(routes: RegisteredRoute[], url: string): RouteHandler {
const registeredRoute = routes.find(({ pattern }) =>
typeof pattern === 'string' ? pattern === url : pattern.test(url)
)
if (!registeredRoute) {
throw new Error(`Expected route handler for ${url}`)
}
return registeredRoute.handler
}
function createRouteInvocation({
url,
method = 'POST',
requestBody
}: {
url: string
method?: string
requestBody?: unknown
}): {
route: Route
continued: ReturnType<typeof vi.fn>
getFulfilled: () => FulfillOptions | undefined
} {
let fulfilled: FulfillOptions | undefined
const continued = vi.fn(async () => {})
const route = {
request: () =>
({
method: () => method,
url: () => url,
postDataJSON: () => requestBody
}) as ReturnType<Route['request']>,
continue: continued,
fulfill: vi.fn(async (options?: FulfillOptions) => {
if (!options) {
throw new Error('Expected route to be fulfilled with options')
}
fulfilled = options
})
} satisfies Pick<Route, 'request' | 'continue' | 'fulfill'>
return {
route: route as unknown as Route,
continued,
getFulfilled: () => fulfilled
}
}
function bodyToText(body: FulfillOptions['body']): string {
if (body instanceof Uint8Array) {
return Buffer.from(body).toString('utf-8')
}
return `${body ?? ''}`
}
async function invokeJsonRoute<T>(
handler: RouteHandler,
args: {
url: string
requestBody?: unknown
}
): Promise<{
status: number | undefined
body: T
}> {
const invocation = createRouteInvocation(args)
await handler(invocation.route)
const fulfilled = invocation.getFulfilled()
expect(fulfilled).toBeDefined()
return {
status: fulfilled?.status,
body: JSON.parse(bodyToText(fulfilled?.body)) as T
}
}
describe('InMemoryJobsBackend', () => {
it('lists jobs sorted by create_time descending by default', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([
createSeededJob({ id: 'job-oldest', createTime: 1_000 }),
createSeededJob({ id: 'job-newest', createTime: 3_000 }),
createSeededJob({ id: 'job-middle', createTime: 2_000 })
])
const listRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs'
)
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
url: 'http://localhost/api/jobs?offset=-1&limit=0'
})
expect(response.body.jobs.map((job) => job.id)).toEqual([
'job-newest',
'job-middle',
'job-oldest'
])
expect(response.body.pagination).toEqual({
offset: 0,
limit: 3,
total: 3,
has_more: false
})
})
it('filters by status and workflow_id, then sorts and paginates by execution_duration', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([
createSeededJob({
id: 'job-fast',
status: 'completed',
workflowId: 'wf-1',
createTime: 1_000,
executionEndTime: 1_100
}),
createSeededJob({
id: 'job-slow',
status: 'completed',
workflowId: 'wf-1',
createTime: 2_000,
executionEndTime: 4_000
}),
createSeededJob({
id: 'job-other-workflow',
status: 'completed',
workflowId: 'wf-2',
createTime: 3_000,
executionEndTime: 8_000
}),
createSeededJob({
id: 'job-pending',
status: 'pending',
workflowId: 'wf-1',
createTime: 4_000
})
])
const listRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs'
)
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
url: 'http://localhost/api/jobs?status=completed&workflow_id=wf-1&sort_by=execution_duration&sort_order=asc&offset=1&limit=1'
})
expect(response.body.jobs.map((job) => job.id)).toEqual(['job-slow'])
expect(response.body.pagination).toEqual({
offset: 1,
limit: 1,
total: 2,
has_more: false
})
})
it('returns job detail responses by id', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
const seededJob = createSeededJob({
id: 'job-detail',
createTime: 5_000,
workflowId: 'wf-detail'
})
await backend.seed([seededJob])
const detailRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs/job-detail'
)
const response = await invokeJsonRoute<JobDetailResponse>(
detailRouteHandler,
{
url: 'http://localhost/api/jobs/job-detail'
}
)
expect(response.status).toBe(200)
expect(response.body).toEqual(seededJob.detail)
})
it('returns 404 for unknown job detail requests', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([])
const detailRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs/missing-job'
)
const response = await invokeJsonRoute<{ error: string }>(
detailRouteHandler,
{
url: 'http://localhost/api/jobs/missing-job'
}
)
expect(response.status).toBe(404)
expect(response.body).toEqual({ error: 'Job not found' })
})
it('clears terminal jobs while preserving in-progress jobs for history clear', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([
createSeededJob({
id: 'job-completed',
status: 'completed',
createTime: 1_000
}),
createSeededJob({
id: 'job-failed',
status: 'failed',
createTime: 2_000
}),
createSeededJob({
id: 'job-running',
status: 'in_progress',
createTime: 3_000
})
])
const historyRouteHandler = getRouteHandler(
routes,
'http://localhost/api/history'
)
const clearInvocation = createRouteInvocation({
url: 'http://localhost/api/history',
requestBody: { clear: true }
})
await historyRouteHandler(clearInvocation.route)
const listRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs'
)
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
url: 'http://localhost/api/jobs'
})
expect(response.body.jobs.map((job) => job.id)).toEqual(['job-running'])
})
it('deletes specific jobs via the history endpoint', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([
createSeededJob({ id: 'job-keep', createTime: 1_000 }),
createSeededJob({ id: 'job-delete', createTime: 2_000 })
])
const historyRouteHandler = getRouteHandler(
routes,
'http://localhost/api/history'
)
const deleteInvocation = createRouteInvocation({
url: 'http://localhost/api/history',
requestBody: { delete: ['job-delete'] }
})
await historyRouteHandler(deleteInvocation.route)
const listRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs'
)
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
url: 'http://localhost/api/jobs'
})
expect(response.body.jobs.map((job) => job.id)).toEqual(['job-keep'])
})
it('falls through non-POST history requests', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([createSeededJob({ id: 'job-history', createTime: 1 })])
const historyRouteHandler = getRouteHandler(
routes,
'http://localhost/api/history'
)
const invocation = createRouteInvocation({
url: 'http://localhost/api/history',
method: 'GET'
})
await historyRouteHandler(invocation.route)
expect(invocation.continued).toHaveBeenCalledTimes(1)
expect(invocation.getFulfilled()).toBeUndefined()
})
})

View File

@@ -0,0 +1,213 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
export type SeededJob = {
listItem: JobEntry
detail: JobDetailResponse
}
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
function getExecutionDuration(job: JobEntry): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
function getJobIdFromRequest(route: Route): string | null {
const url = new URL(route.request().url())
const jobId = url.pathname.split('/').at(-1)
return jobId ? decodeURIComponent(jobId) : null
}
export class InMemoryJobsBackend {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private seededJobs = new Map<string, SeededJob>()
constructor(private readonly page: Page) {}
async seed(jobs: SeededJob[]): Promise<void> {
this.seededJobs = new Map(
jobs.map((job) => [job.listItem.id, job] satisfies [string, SeededJob])
)
await this.ensureRoutesRegistered()
}
async clear(): Promise<void> {
this.seededJobs.clear()
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
let filteredJobs = Array.from(
this.seededJobs.values(),
({ listItem }) => listItem
)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = getJobIdFromRequest(route)
const job = jobId ? this.seededJobs.get(jobId) : undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const requestBody = request.postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.seededJobs = new Map(
Array.from(this.seededJobs).filter(([, job]) => {
const status = job.listItem.status
return status === 'pending' || status === 'in_progress'
})
)
}
if (requestBody?.delete?.length) {
for (const jobId of requestBody.delete) {
this.seededJobs.delete(jobId)
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -15,7 +15,7 @@ export class KeyboardHelper {
locator: Locator | null = this.canvas
): Promise<void> {
const target = locator ?? this.page.keyboard
await target.press(`Control+${keyToPress}`)
await target.press(`ControlOrMeta+${keyToPress}`)
await this.nextFrame()
}

View File

@@ -0,0 +1,32 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { ResultItemType } from '@/schemas/apiSchema'
export type ImportedAssetFixture = {
name: string
filePath?: string
contentType?: string
}
export type GeneratedOutputFixture = {
filename: string
displayName?: string
filePath?: string
contentType?: string
mediaType?: 'images' | 'video' | 'audio'
subfolder?: string
type?: ResultItemType
}
export type GeneratedJobFixture = {
jobId: string
status?: JobEntry['status']
outputs: [GeneratedOutputFixture, ...GeneratedOutputFixture[]]
createdAt?: string
createTime?: number
executionStartTime?: number
executionEndTime?: number
workflowId?: string
workflow?: JobDetailResponse['workflow']
nodeId?: string
}

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { buildMockJobOutputs } from '@e2e/fixtures/helpers/buildMockJobOutputs'
import type {
GeneratedJobFixture,
GeneratedOutputFixture
} from '@e2e/fixtures/helpers/assetScenarioTypes'
describe('buildMockJobOutputs', () => {
it('defaults nodeId, mediaType, subfolder, and type for a single output', () => {
const job = {
jobId: 'job-1',
outputs: [{ filename: 'single-output.png' }]
} satisfies GeneratedJobFixture
const outputs = [
{
filename: 'single-output.png',
displayName: 'Single output'
}
] satisfies GeneratedOutputFixture[]
expect(buildMockJobOutputs(job, outputs)).toEqual({
'5': {
images: [
{
filename: 'single-output.png',
subfolder: '',
type: 'output',
display_name: 'Single output'
}
]
}
})
})
it('buckets outputs by media type and preserves order within each bucket', () => {
const job = {
jobId: 'job-2',
nodeId: '12',
outputs: [{ filename: 'preview.png' }]
} satisfies GeneratedJobFixture
const outputs = [
{
filename: 'image-a.png',
mediaType: 'images',
subfolder: 'gallery',
type: 'temp'
},
{
filename: 'clip.mp4',
mediaType: 'video',
displayName: 'Clip'
},
{
filename: 'image-b.png',
mediaType: 'images',
displayName: 'Second image'
},
{
filename: 'sound.wav',
mediaType: 'audio'
}
] satisfies GeneratedOutputFixture[]
expect(buildMockJobOutputs(job, outputs)).toEqual({
'12': {
images: [
{
filename: 'image-a.png',
subfolder: 'gallery',
type: 'temp',
display_name: undefined
},
{
filename: 'image-b.png',
subfolder: '',
type: 'output',
display_name: 'Second image'
}
],
video: [
{
filename: 'clip.mp4',
subfolder: '',
type: 'output',
display_name: 'Clip'
}
],
audio: [
{
filename: 'sound.wav',
subfolder: '',
type: 'output',
display_name: undefined
}
]
}
})
})
})

View File

@@ -0,0 +1,34 @@
import type { JobDetailResponse } from '@comfyorg/ingest-types'
import type { TaskOutput } from '@/schemas/apiSchema'
import type {
GeneratedJobFixture,
GeneratedOutputFixture
} from '@e2e/fixtures/helpers/assetScenarioTypes'
export function buildMockJobOutputs(
job: GeneratedJobFixture,
outputs: GeneratedOutputFixture[]
): NonNullable<JobDetailResponse['outputs']> {
const nodeId = job.nodeId ?? '5'
const nodeOutputs: Pick<TaskOutput[string], 'audio' | 'images' | 'video'> = {}
for (const output of outputs) {
const mediaType = output.mediaType ?? 'images'
nodeOutputs[mediaType] = [
...(nodeOutputs[mediaType] ?? []),
{
filename: output.filename,
subfolder: output.subfolder ?? '',
type: output.type ?? 'output',
display_name: output.displayName
}
]
}
const taskOutput = { [nodeId]: nodeOutputs } satisfies TaskOutput
return taskOutput
}

View File

@@ -0,0 +1,47 @@
import type { JobEntry } from '@comfyorg/ingest-types'
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
export function createMockJobs(
count: number,
baseOverrides?: Partial<JobEntry>
): JobEntry[] {
const now = Date.now()
return Array.from({ length: count }, (_, index) =>
createMockJob({
id: `job-${String(index + 1).padStart(3, '0')}`,
create_time: now - index * 60_000,
execution_start_time: now - index * 60_000,
execution_end_time: now - index * 60_000 + (5 + index) * 1_000,
preview_output: {
filename: `image_${String(index + 1).padStart(3, '0')}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
...baseOverrides
})
)
}

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import {
buildSeededFileKey,
buildSeededFiles
} from '@e2e/fixtures/helpers/seededAssetFiles'
describe('buildSeededFiles', () => {
it('keys seeded files by filename, type, and subfolder', () => {
const seededFiles = buildSeededFiles({
generated: [
{
jobId: 'job-root',
outputs: [
{
filename: 'shared-name.txt',
type: 'output',
subfolder: '',
filePath: '/tmp/root-output.txt',
contentType: 'text/plain'
}
]
},
{
jobId: 'job-nested',
outputs: [
{
filename: 'shared-name.txt',
type: 'output',
subfolder: 'nested/folder',
filePath: '/tmp/nested-output.txt',
contentType: 'text/plain'
}
]
}
],
imported: [
{
name: 'shared-name.txt',
filePath: '/tmp/input-asset.txt',
contentType: 'text/plain'
}
]
})
expect(
seededFiles.get(
buildSeededFileKey({
filename: 'shared-name.txt',
type: 'output',
subfolder: ''
})
)
).toMatchObject({
filePath: '/tmp/root-output.txt',
contentType: 'text/plain'
})
expect(
seededFiles.get(
buildSeededFileKey({
filename: 'shared-name.txt',
type: 'output',
subfolder: 'nested/folder'
})
)
).toMatchObject({
filePath: '/tmp/nested-output.txt',
contentType: 'text/plain'
})
expect(
seededFiles.get(
buildSeededFileKey({
filename: 'shared-name.txt',
type: 'input',
subfolder: ''
})
)
).toMatchObject({
filePath: '/tmp/input-asset.txt',
contentType: 'text/plain'
})
})
})

View File

@@ -0,0 +1,142 @@
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import type {
GeneratedJobFixture,
GeneratedOutputFixture,
ImportedAssetFixture
} from '@e2e/fixtures/helpers/assetScenarioTypes'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
const helperDir = path.dirname(fileURLToPath(import.meta.url))
export type SeededAssetFile = {
filePath?: string
contentType?: string
textContent?: string
}
export type SeededFileLocation = {
filename: string
type: string
subfolder: string
}
function getFixturePath(relativePath: string): string {
return path.resolve(helperDir, '../../assets', relativePath)
}
export function buildSeededFileKey({
filename,
type,
subfolder
}: SeededFileLocation): string {
return new URLSearchParams({
filename,
type,
subfolder
}).toString()
}
export function defaultFileFor(filename: string): SeededAssetFile {
const normalized = filename.toLowerCase()
if (normalized.endsWith('.png')) {
return {
filePath: getFixturePath('workflowInMedia/workflow_itxt.png'),
contentType: 'image/png'
}
}
if (normalized.endsWith('.webp')) {
return {
filePath: getFixturePath('example.webp'),
contentType: 'image/webp'
}
}
if (normalized.endsWith('.webm')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.webm'),
contentType: 'video/webm'
}
}
if (normalized.endsWith('.mp4')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
contentType: 'video/mp4'
}
}
if (normalized.endsWith('.glb')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.glb'),
contentType: 'model/gltf-binary'
}
}
if (normalized.endsWith('.json')) {
return {
textContent: JSON.stringify({ mocked: true }, null, 2),
contentType: 'application/json'
}
}
return {
textContent: 'mocked asset content',
contentType: getMimeType(filename)
}
}
function outputLocation(output: GeneratedOutputFixture): SeededFileLocation {
return {
filename: output.filename,
type: output.type ?? 'output',
subfolder: output.subfolder ?? ''
}
}
function importedAssetLocation(
asset: ImportedAssetFixture
): SeededFileLocation {
return {
filename: asset.name,
type: 'input',
subfolder: ''
}
}
export function buildSeededFiles({
generated,
imported
}: {
generated: readonly GeneratedJobFixture[]
imported: readonly ImportedAssetFixture[]
}): Map<string, SeededAssetFile> {
const seededFiles = new Map<string, SeededAssetFile>()
for (const job of generated) {
for (const output of job.outputs) {
const fallback = defaultFileFor(output.filename)
seededFiles.set(buildSeededFileKey(outputLocation(output)), {
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType,
textContent: fallback.textContent
})
}
}
for (const asset of imported) {
const fallback = defaultFileFor(asset.name)
seededFiles.set(buildSeededFileKey(importedAssetLocation(asset)), {
filePath: asset.filePath ?? fallback.filePath,
contentType: asset.contentType ?? fallback.contentType,
textContent: fallback.textContent
})
}
return seededFiles
}

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
createAssetHelper,
withModels,
@@ -17,6 +18,8 @@ import {
STABLE_OUTPUT
} from '@e2e/fixtures/data/assetFixtures'
const test = mergeTests(comfyPageFixture, assetApiFixture)
test.describe('AssetHelper', () => {
test.describe('operators and configuration', () => {
test('creates helper with models via withModels operator', async ({
@@ -66,8 +69,7 @@ test.describe('AssetHelper', () => {
})
test.describe('mock API routes', () => {
test('GET /assets returns all assets', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_INPUT_IMAGE)
@@ -87,12 +89,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(2)
expect(data.has_more).toBe(false)
await assetApi.clearMocks()
})
test('GET /assets respects pagination params', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets respects pagination params', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withModels(5),
withPagination({ total: 10, hasMore: true })
@@ -110,12 +112,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(10)
expect(data.has_more).toBe(true)
await assetApi.clearMocks()
})
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets filters by include_tags', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
@@ -129,14 +131,12 @@ test.describe('AssetHelper', () => {
const data = body as { assets: Array<{ id: string }> }
expect(data.assets).toHaveLength(1)
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
await assetApi.clearMocks()
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -151,12 +151,12 @@ test.describe('AssetHelper', () => {
`${comfyPage.url}/api/assets/nonexistent-id`
)
expect(notFound.status).toBe(404)
await assetApi.clearMocks()
})
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('PUT /assets/:id updates asset in store', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -175,14 +175,12 @@ test.describe('AssetHelper', () => {
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
'renamed.safetensors'
)
await assetApi.clearMocks()
})
test('DELETE /assets/:id removes asset from store', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
await assetApi.mock()
@@ -193,11 +191,12 @@ test.describe('AssetHelper', () => {
expect(status).toBe(204)
expect(assetApi.assetCount).toBe(1)
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
await assetApi.clearMocks()
})
test('POST /assets returns upload response', async ({ comfyPage }) => {
test('POST /assets returns upload response', async ({
comfyPage,
assetApi
}) => {
const customUpload = {
id: 'custom-upload-001',
name: 'custom.safetensors',
@@ -205,7 +204,6 @@ test.describe('AssetHelper', () => {
created_at: '2025-01-01T00:00:00Z',
created_new: true
}
const { assetApi } = comfyPage
assetApi.configure(withUploadResponse(customUpload))
await assetApi.mock()
@@ -217,14 +215,12 @@ test.describe('AssetHelper', () => {
const data = body as { id: string; name: string }
expect(data.id).toBe('custom-upload-001')
expect(data.name).toBe('custom.safetensors')
await assetApi.clearMocks()
})
test('POST /assets/download returns async download response', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
await assetApi.mock()
const { status, body } = await assetApi.fetch(
@@ -235,14 +231,14 @@ test.describe('AssetHelper', () => {
const data = body as { task_id: string; status: string }
expect(data.task_id).toBe('download-task-001')
expect(data.status).toBe('created')
await assetApi.clearMocks()
})
})
test.describe('mutation tracking', () => {
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('tracks POST, PUT, DELETE mutations', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -265,12 +261,12 @@ test.describe('AssetHelper', () => {
expect(mutations[0].method).toBe('POST')
expect(mutations[1].method).toBe('PUT')
expect(mutations[2].method).toBe('DELETE')
await assetApi.clearMocks()
})
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET requests are not tracked as mutations', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -280,14 +276,14 @@ test.describe('AssetHelper', () => {
)
expect(assetApi.getMutations()).toHaveLength(0)
await assetApi.clearMocks()
})
})
test.describe('mockError', () => {
test('returns error status for all asset routes', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('returns error status for all asset routes', async ({
comfyPage,
assetApi
}) => {
await assetApi.mockError(503, 'Service Unavailable')
const { status, body } = await assetApi.fetch(
@@ -296,16 +292,14 @@ test.describe('AssetHelper', () => {
expect(status).toBe(503)
const data = body as { error: string }
expect(data.error).toBe('Service Unavailable')
await assetApi.clearMocks()
})
})
test.describe('clearMocks', () => {
test('resets store, mutations, and unroutes handlers', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()

View File

@@ -1,13 +1,16 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
const now = Date.now()
const MOCK_JOBS: RawJobListItem[] = [
const MOCK_JOBS: JobEntry[] = [
createMockJob({
id: 'job-completed-1',
status: 'completed',
@@ -35,16 +38,14 @@ const MOCK_JOBS: RawJobListItem[] = [
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(MOCK_JOBS)
await comfyPage.setupSettings({
'Comfy.Queue.QPOV2': false
})
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()

View File

@@ -1,22 +1,21 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
import {
createMockJob,
createMockJobs
} from '@e2e/fixtures/helpers/AssetsHelper'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
} from '@e2e/fixtures/helpers/jobFixtures'
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
const SAMPLE_JOBS: RawJobListItem[] = [
const SAMPLE_JOBS: JobEntry[] = [
createMockJob({
id: 'job-alpha',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
create_time: 1_000_000,
execution_start_time: 1_000_000,
execution_end_time: 1_010_000,
preview_output: {
filename: 'landscape.png',
subfolder: '',
@@ -28,9 +27,9 @@ const SAMPLE_JOBS: RawJobListItem[] = [
}),
createMockJob({
id: 'job-beta',
create_time: 2000,
execution_start_time: 2000,
execution_end_time: 2003,
create_time: 2_000_000,
execution_start_time: 2_000_000,
execution_end_time: 2_003_000,
preview_output: {
filename: 'portrait.png',
subfolder: '',
@@ -42,9 +41,9 @@ const SAMPLE_JOBS: RawJobListItem[] = [
}),
createMockJob({
id: 'job-gamma',
create_time: 3000,
execution_start_time: 3000,
execution_end_time: 3020,
create_time: 3_000_000,
execution_start_time: 3_000_000,
execution_end_time: 3_020_000,
preview_output: {
filename: 'abstract_art.png',
subfolder: '',
@@ -62,20 +61,12 @@ const SAMPLE_IMPORTED_FILES = [
'audio_clip.wav'
]
// ==========================================================================
// 1. Empty states
// ==========================================================================
test.describe('Assets sidebar - empty states', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedEmptyState()
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
@@ -101,21 +92,13 @@ test.describe('Assets sidebar - empty states', () => {
})
})
// ==========================================================================
// 2. Tab navigation
// ==========================================================================
test.describe('Assets sidebar - tab navigation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
await assetScenario.seedImportedFiles(SAMPLE_IMPORTED_FILES)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Generated tab is active by default', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
@@ -130,12 +113,10 @@ test.describe('Assets sidebar - tab navigation', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Switch to Imported
await tab.switchToImported()
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
// Switch back to Generated
await tab.switchToGenerated()
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
})
@@ -144,31 +125,21 @@ test.describe('Assets sidebar - tab navigation', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Type search in Generated tab
await tab.searchInput.fill('landscape')
await expect(tab.searchInput).toHaveValue('landscape')
// Switch to Imported tab
await tab.switchToImported()
await expect(tab.searchInput).toHaveValue('')
})
})
// ==========================================================================
// 3. Asset display - grid view
// ==========================================================================
test.describe('Assets sidebar - grid view display', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
await assetScenario.seedImportedFiles(SAMPLE_IMPORTED_FILES)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Displays generated assets as cards in grid view', async ({
comfyPage
}) => {
@@ -193,8 +164,8 @@ test.describe('Assets sidebar - grid view display', () => {
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
})
test('Displays svg outputs', async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
test('Displays svg outputs', async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory([
createMockJob({
id: 'job-alpha',
create_time: 1000,
@@ -218,31 +189,22 @@ test.describe('Assets sidebar - grid view display', () => {
})
})
// ==========================================================================
// 4. View mode toggle (grid <-> list)
// ==========================================================================
test.describe('Assets sidebar - view mode toggle', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
await assetScenario.seedImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Open settings menu and select list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
// List view items should now be visible
await expect(tab.assetCards).toHaveCount(0)
await expect(tab.listViewItems.first()).toBeVisible()
})
@@ -251,12 +213,10 @@ test.describe('Assets sidebar - view mode toggle', () => {
await tab.open()
await tab.waitForAssets()
// Switch to list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
await expect(tab.listViewItems.first()).toBeVisible()
// Switch back to grid view (settings popover is still open)
await tab.gridViewOption.click()
await tab.waitForAssets()
@@ -265,21 +225,13 @@ test.describe('Assets sidebar - view mode toggle', () => {
})
})
// ==========================================================================
// 5. Search functionality
// ==========================================================================
test.describe('Assets sidebar - search', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
await assetScenario.seedImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Search input is visible', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
@@ -296,7 +248,6 @@ test.describe('Assets sidebar - search', () => {
const initialCount = await tab.assetCards.count()
// Search for a specific filename that matches only one asset
await tab.searchInput.fill('landscape')
// Wait for filter to reduce the count
@@ -310,7 +261,6 @@ test.describe('Assets sidebar - search', () => {
const initialCount = await tab.assetCards.count()
// Filter then clear
await tab.searchInput.fill('landscape')
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
@@ -328,30 +278,20 @@ test.describe('Assets sidebar - search', () => {
})
})
// ==========================================================================
// 6. Asset selection
// ==========================================================================
test.describe('Assets sidebar - selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
await assetScenario.seedImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Clicking an asset card selects it', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Click first asset card
await tab.assetCards.first().click()
// Should have data-selected="true"
await expect(tab.selectedCards).toHaveCount(1)
})
@@ -363,11 +303,9 @@ test.describe('Assets sidebar - selection', () => {
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
// Click first card
await cards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Ctrl+click second card
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
await expect(tab.selectedCards).toHaveCount(2)
})
@@ -379,7 +317,6 @@ test.describe('Assets sidebar - selection', () => {
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
// Footer should show selection count
@@ -391,15 +328,12 @@ test.describe('Assets sidebar - selection', () => {
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Hover over the selection count button to reveal "Deselect all"
await tab.selectionCountButton.hover()
await expect(tab.deselectAllButton).toBeVisible()
// Click "Deselect all"
await tab.deselectAllButton.click()
await expect(tab.selectedCards).toHaveCount(0)
})
@@ -409,44 +343,31 @@ test.describe('Assets sidebar - selection', () => {
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Switch to Imported tab
await tab.switchToImported()
// Switch back - selection should be cleared
await tab.switchToGenerated()
await tab.waitForAssets()
await expect(tab.selectedCards).toHaveCount(0)
})
})
// ==========================================================================
// 7. Context menu
// ==========================================================================
test.describe('Assets sidebar - context menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
await assetScenario.seedImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Right-click first asset
await tab.assetCards.first().click({ button: 'right' })
// Context menu should appear with standard items
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
})
@@ -539,23 +460,17 @@ test.describe('Assets sidebar - context menu', () => {
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
// Dismiss any toasts that appeared after asset loading
await tab.dismissToasts()
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
// the modifier — click({ modifiers }) only sets the mouse event flag and
// does not fire a keydown event that VueUse tracks.
await cards.first().click()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('ControlOrMeta')
await cards.nth(1).click()
await comfyPage.page.keyboard.up('Control')
await comfyPage.page.keyboard.up('ControlOrMeta')
// Verify multi-selection took effect and footer is stable before right-clicking
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
// overlay intercepting the event, and assert directly without toPass.
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await cards.first().dispatchEvent('contextmenu', {
bubbles: true,
@@ -564,26 +479,17 @@ test.describe('Assets sidebar - context menu', () => {
})
await expect(contextMenu).toBeVisible()
// Bulk menu should show bulk download action
await expect(tab.contextMenuItem('Download all')).toBeVisible()
})
})
// ==========================================================================
// 8. Bulk actions (footer)
// ==========================================================================
test.describe('Assets sidebar - bulk actions', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
await assetScenario.seedImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Footer shows download button when assets selected', async ({
comfyPage
}) => {
@@ -615,17 +521,14 @@ test.describe('Assets sidebar - bulk actions', () => {
await tab.open()
await tab.waitForAssets()
// Select the two single-output assets (job-alpha, job-beta).
// The count reflects total outputs, not cards — job-gamma has
// outputs_count: 2 which would inflate the total.
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
await cards.nth(1).click()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('ControlOrMeta')
await cards.nth(2).click()
await comfyPage.page.keyboard.up('Control')
await comfyPage.page.keyboard.up('ControlOrMeta')
// Selection count should show the count
await expect(tab.selectionCountButton).toBeVisible()
@@ -633,84 +536,13 @@ test.describe('Assets sidebar - bulk actions', () => {
})
})
// ==========================================================================
// 9. Pagination
// ==========================================================================
test.describe('Assets sidebar - pagination', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('initial load fetches first batch with offset 0', async ({
comfyPage
}) => {
const manyJobs = createMockJobs(250)
await comfyPage.assets.mockOutputHistory(manyJobs)
await comfyPage.setup()
// Capture the first history fetch (terminal statuses only).
// Queue polling also hits /jobs but with status=in_progress,pending.
const firstRequest = comfyPage.page.waitForRequest((req) => {
if (!/\/api\/jobs\?/.test(req.url())) return false
const url = new URL(req.url())
const status = url.searchParams.get('status') ?? ''
return status.includes('completed')
})
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const req = await firstRequest
const url = new URL(req.url())
expect(url.searchParams.get('offset')).toBe('0')
expect(Number(url.searchParams.get('limit'))).toBeGreaterThan(0)
})
})
// ==========================================================================
// 10. Settings menu visibility
// ==========================================================================
test.describe('Assets sidebar - settings menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Settings menu shows view mode options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openSettingsMenu()
await expect(tab.listViewOption).toBeVisible()
await expect(tab.gridViewOption).toBeVisible()
})
})
// ==========================================================================
// 11. Delete confirmation
// ==========================================================================
test.describe('Assets sidebar - delete confirmation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockDeleteHistory()
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
await assetScenario.seedImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Right-click delete shows confirmation dialog', async ({
comfyPage
}) => {
@@ -744,7 +576,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await comfyPage.confirmDialog.delete.click()
await comfyPage.confirmDialog.click('delete')
await expect(dialog).toBeHidden()
await expect(tab.assetCards).toHaveCount(initialCount - 1)
@@ -766,9 +598,54 @@ test.describe('Assets sidebar - delete confirmation', () => {
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await comfyPage.confirmDialog.reject.click()
await comfyPage.confirmDialog.click('reject')
await expect(dialog).toBeHidden()
await expect(tab.assetCards).toHaveCount(initialCount)
})
})
test.describe('Assets sidebar - pagination', () => {
test('initial load fetches first batch with offset 0', async ({
comfyPage,
assetScenario
}) => {
const manyJobs = createMockJobs(250)
await assetScenario.seedGeneratedHistory(manyJobs)
await comfyPage.setup()
const firstRequest = comfyPage.page.waitForRequest((req) => {
if (!/\/api\/jobs\?/.test(req.url())) return false
const url = new URL(req.url())
const status = url.searchParams.get('status') ?? ''
return status.includes('completed')
})
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const req = await firstRequest
const url = new URL(req.url())
expect(url.searchParams.get('offset')).toBe('0')
expect(Number(url.searchParams.get('limit'))).toBeGreaterThan(0)
})
})
test.describe('Assets sidebar - settings menu', () => {
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
await assetScenario.seedImportedFiles([])
await comfyPage.setup()
})
test('Settings menu shows view mode options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openSettingsMenu()
await expect(tab.listViewOption).toBeVisible()
await expect(tab.gridViewOption).toBeVisible()
})
})

View File

@@ -17,7 +17,8 @@ The ComfyUI Frontend project uses **colocated tests** - test files are placed al
- **Component Tests**: Located directly alongside their components (e.g., `MyComponent.test.ts` next to `MyComponent.vue`)
- **Unit Tests**: Located alongside their source files (e.g., `myUtil.test.ts` next to `myUtil.ts`)
- **Store Tests**: Located in `src/stores/` alongside their store files
- **Browser Tests**: Located in the `browser_tests/` directory (see dedicated README there)
- **Browser Tests**: Playwright specs live in `browser_tests/tests/**/*.spec.ts` (see dedicated README there)
- **Browser Test Helper Unit Tests**: Vitest tests for browser fixtures/helpers may be colocated in `browser_tests/` as `*.test.ts`, outside `browser_tests/tests/`
### Test File Naming

View File

@@ -23,6 +23,7 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
export default defineConfig({
testDir: './browser_tests',
testMatch: ['tests/**/*.spec.ts'],
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: 'html',

View File

@@ -14,6 +14,7 @@
<button
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
role="button"
:aria-label="$t('mediaAsset.actions.copyJobId')"
@click="copyJobId"
>
<i class="icon-[lucide--copy] text-sm"></i>
@@ -118,6 +119,8 @@
<div
v-if="hasSelection"
ref="footerRef"
role="toolbar"
:aria-label="$t('mediaAsset.selection.actions')"
class="flex h-18 w-full items-center justify-between gap-1"
>
<div class="flex-1 pl-4">
@@ -143,6 +146,7 @@
<Button
v-if="shouldShowDeleteButton"
size="icon"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
@@ -150,6 +154,7 @@
</Button>
<Button
size="icon"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>

View File

@@ -3085,6 +3085,7 @@
"selection": {
"selectedCount": "Assets Selected: {count}",
"multipleSelectedAssets": "Multiple assets selected",
"actions": "Selected asset actions",
"deselectAll": "Deselect all",
"downloadSelected": "Download",
"downloadSelectedAll": "Download all",

View File

@@ -123,6 +123,7 @@
$t('mediaAsset.actions.seeMoreOutputs')
"
variant="secondary"
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />

View File

@@ -23,8 +23,8 @@ const mockWorkflow: ComfyWorkflowJSON = {
const mockJobDetailResponse: JobDetail = {
id: 'test-job-id',
status: 'completed',
create_time: 1234567890,
update_time: 1234567900,
create_time: 1234567890000,
update_time: 1234567900000,
workflow: {
extra_data: {
extra_pnginfo: {

View File

@@ -634,6 +634,7 @@ export default defineConfig({
'@/utils/formatUtil': '/packages/shared-frontend-utils/src/formatUtil.ts',
'@/utils/networkUtil':
'/packages/shared-frontend-utils/src/networkUtil.ts',
'@e2e': '/browser_tests',
'@': '/src'
}
},
@@ -650,6 +651,7 @@ export default defineConfig({
setupFiles: ['./vitest.setup.ts'],
retry: process.env.CI ? 2 : 0,
include: [
'browser_tests/**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'packages/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'scripts/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'