test: add assets sidebar playwright foundation

This commit is contained in:
Benjamin Lu
2026-03-27 13:56:07 -07:00
parent 070a5f59fe
commit adf17e516b
7 changed files with 683 additions and 117 deletions

View File

@@ -174,6 +174,10 @@ export class AssetsSidebarTab extends SidebarTab {
super(page, 'assets')
}
get root() {
return this.page.locator('.sidebar-content-container')
}
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
@@ -188,12 +192,143 @@ export class AssetsSidebarTab extends SidebarTab {
)
}
get searchInput() {
return this.root.getByPlaceholder(/Search Assets/i)
}
get viewSettingsButton() {
return this.root.getByLabel('View settings')
}
get listViewButton() {
return this.page.getByRole('button', { name: 'List view' })
}
get gridViewButton() {
return this.page.getByRole('button', { name: 'Grid view' })
}
get backButton() {
return this.page.getByRole('button', { name: 'Back to all assets' })
}
get copyJobIdButton() {
return this.page.getByRole('button', { name: 'Copy job ID' })
}
get previewDialog() {
return this.page.getByRole('dialog', { name: 'Gallery' })
}
get selectionCountButton() {
return this.root.getByRole('button', { name: /Assets Selected:/ })
}
get downloadSelectionButton() {
return this.page.getByRole('button', { name: 'Download' })
}
get deleteSelectionButton() {
return this.page.getByRole('button', { name: 'Delete' })
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
previewImage(filename: string) {
return this.previewDialog.getByRole('img', { name: filename })
}
asset(name: string) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return this.root.getByRole('button', {
name: new RegExp(`^${escaped} - .* asset$`)
})
}
contextMenuAction(name: string) {
return this.page.getByRole('button', { name })
}
async showGenerated() {
await this.generatedTab.click()
}
async showImported() {
await this.importedTab.click()
}
async search(query: string) {
await this.searchInput.fill(query)
}
async switchToListView() {
await this.viewSettingsButton.click()
await this.listViewButton.click()
}
async switchToGridView() {
await this.viewSettingsButton.click()
await this.gridViewButton.click()
}
async openContextMenuForAsset(name: string) {
await this.asset(name).click({ button: 'right' })
}
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.page
.getByRole('button', { name: 'Back to all assets' })
.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()
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
for (const name of names.slice(1)) {
await this.asset(name).click({
modifiers: [modifier]
})
}
}
override async open() {
await super.open()
await this.root.waitFor({ state: 'visible' })
await this.generatedTab.waitFor({ state: 'visible' })
}
}

View File

@@ -1,140 +1,292 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import type { Page, Route } from '@playwright/test'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
import type {
JobDetail,
RawJobListItem
} from '../../../src/platform/remote/comfyui/jobs/jobTypes'
import type { ResultItemType, TaskOutput } from '../../../src/schemas/apiSchema'
import { JobsApiMock, type SeededJob } from './JobsApiMock'
import { getMimeType } from './mimeTypeUtil'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
const helperDir = path.dirname(fileURLToPath(import.meta.url))
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
type SeededAssetFile = {
filePath?: string
contentType?: string
textContent?: string
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
export type ImportedAssetSeed = {
name: string
filePath?: string
contentType?: string
}
function getExecutionDuration(job: RawJobListItem): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
export type GeneratedAssetOutputSeed = {
filename: string
displayName?: string
filePath?: string
contentType?: string
mediaType?: 'images' | 'video' | 'audio'
subfolder?: string
type?: ResultItemType
}
export type GeneratedJobSeed = {
jobId: string
outputs: [GeneratedAssetOutputSeed, ...GeneratedAssetOutputSeed[]]
createdAt?: string
createTime?: number
executionStartTime?: number
executionEndTime?: number
workflowId?: string | null
workflow?: unknown
nodeId?: string
}
function getFixturePath(relativePath: string): string {
return path.resolve(helperDir, '../../assets', relativePath)
}
function defaultFileFor(filename: string): SeededAssetFile {
const name = filename.toLowerCase()
if (name.endsWith('.png')) {
return {
filePath: getFixturePath('workflowInMedia/workflow_itxt.png'),
contentType: 'image/png'
}
}
if (name.endsWith('.webp')) {
return {
filePath: getFixturePath('example.webp'),
contentType: 'image/webp'
}
}
if (name.endsWith('.webm')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.webm'),
contentType: 'video/webm'
}
}
if (name.endsWith('.mp4')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
contentType: 'video/mp4'
}
}
if (name.endsWith('.glb')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.glb'),
contentType: 'model/gltf-binary'
}
}
if (name.endsWith('.json')) {
return {
textContent: JSON.stringify({ mocked: true }, null, 2),
contentType: 'application/json'
}
}
return {
textContent: 'mocked asset content',
contentType: getMimeType(filename)
}
}
function normalizeOutputSeed(
output: GeneratedAssetOutputSeed
): GeneratedAssetOutputSeed {
const fallback = defaultFileFor(output.filename)
return {
mediaType: 'images',
subfolder: '',
type: 'output',
...output,
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType
}
}
function buildTaskOutput(
jobSeed: GeneratedJobSeed,
outputs: GeneratedAssetOutputSeed[]
): TaskOutput {
const nodeId = jobSeed.nodeId ?? '5'
return {
[nodeId]: {
[outputs[0].mediaType ?? 'images']: outputs.map((output) => ({
filename: output.filename,
subfolder: output.subfolder ?? '',
type: output.type ?? 'output',
display_name: output.displayName
}))
}
}
}
function buildSeededJob(jobSeed: GeneratedJobSeed): SeededJob {
const outputs = jobSeed.outputs.map(normalizeOutputSeed)
const preview = outputs[0]
const createTime =
jobSeed.createTime ??
new Date(jobSeed.createdAt ?? '2026-03-27T12:00:00.000Z').getTime()
const executionStartTime = jobSeed.executionStartTime ?? createTime
const executionEndTime = jobSeed.executionEndTime ?? createTime + 2_000
const listItem: RawJobListItem = {
id: jobSeed.jobId,
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: jobSeed.nodeId ?? '5',
mediaType: preview.mediaType ?? 'images',
display_name: preview.displayName
},
outputs_count: outputs.length,
workflow_id: jobSeed.workflowId ?? null
}
const detail: JobDetail = {
...listItem,
workflow: jobSeed.workflow,
outputs: buildTaskOutput(jobSeed, outputs),
update_time: executionEndTime
}
return { listItem, detail }
}
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private readonly jobsApiMock: JobsApiMock
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private importedFiles: string[] = []
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
private generatedJobs: GeneratedJobSeed[] = []
private importedFiles: ImportedAssetSeed[] = []
private seededFiles = new Map<string, SeededAssetFile>()
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)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
})
}
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
constructor(private readonly page: Page) {
this.jobsApiMock = new JobsApiMock(page)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
generatedImage(
options: Partial<Omit<GeneratedJobSeed, 'outputs'>> & {
filename: string
displayName?: string
filePath?: string
contentType?: string
}
): GeneratedJobSeed {
const {
filename,
displayName,
filePath,
contentType,
jobId = `job-${filename.replace(/\W+/g, '-').toLowerCase()}`,
...rest
} = options
if (this.inputFilesRouteHandler) {
return
return {
jobId,
outputs: [
{
filename,
displayName,
filePath,
contentType,
mediaType: 'images'
}
],
...rest
}
}
importedImage(options: ImportedAssetSeed): ImportedAssetSeed {
return { ...options }
}
async workflowContainerFromFixture(
relativePath: string = 'default.json'
): Promise<GeneratedJobSeed['workflow']> {
const workflow = JSON.parse(
await readFile(getFixturePath(relativePath), 'utf-8')
)
return {
extra_data: {
extra_pnginfo: {
workflow
}
}
}
}
async seedAssets({
generated = [],
imported = []
}: {
generated?: GeneratedJobSeed[]
imported?: ImportedAssetSeed[]
}): Promise<void> {
this.generatedJobs = [...generated]
this.importedFiles = [...imported]
this.seededFiles = new Map()
for (const job of this.generatedJobs) {
for (const output of job.outputs) {
const fallback = defaultFileFor(output.filename)
this.seededFiles.set(output.filename, {
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType,
textContent: fallback.textContent
})
}
}
this.inputFilesRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles)
for (const asset of this.importedFiles) {
const fallback = defaultFileFor(asset.name)
this.seededFiles.set(asset.name, {
filePath: asset.filePath ?? fallback.filePath,
contentType: asset.contentType ?? fallback.contentType,
textContent: fallback.textContent
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
await this.jobsApiMock.seedJobs(this.generatedJobs.map(buildSeededJob))
await this.ensureInputFilesRoute()
await this.ensureViewRoute()
}
async mockEmptyState(): Promise<void> {
await this.mockOutputHistory([])
await this.mockInputFiles([])
await this.seedAssets({ generated: [], imported: [] })
}
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
this.seededFiles.clear()
if (this.jobsRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
this.jobsRouteHandler = null
}
await this.jobsApiMock.clearMocks()
if (this.inputFilesRouteHandler) {
await this.page.unroute(
@@ -143,5 +295,67 @@ export class AssetsHelper {
)
this.inputFilesRouteHandler = null
}
if (this.viewRouteHandler) {
await this.page.unroute(viewRoutePattern, this.viewRouteHandler)
this.viewRouteHandler = null
}
}
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')
if (!filename) {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'Missing filename' })
})
return
}
const seededFile =
this.seededFiles.get(filename) ?? 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

@@ -0,0 +1,194 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetail,
RawJobListItem
} from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
export type SeededJob = {
listItem: RawJobListItem
detail: JobDetail
}
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 JobsApiMock {
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: SeededJob[] = []
constructor(private readonly page: Page) {}
async seedJobs(jobs: SeededJob[]): Promise<void> {
this.seededJobs = [...jobs]
await this.ensureRoutesRegistered()
}
async clearMocks(): Promise<void> {
this.seededJobs = []
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 = this.seededJobs.map(({ 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)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = route
.request()
.url()
.split('/api/jobs/')[1]
?.split('?')[0]
const job = jobId
? this.seededJobs.find(({ listItem }) => listItem.id === 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 requestBody = route.request().postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.seededJobs = this.seededJobs.filter(
({ listItem }) =>
listItem.status === 'pending' || listItem.status === 'in_progress'
)
}
if (requestBody?.delete?.length) {
const deletedIds = new Set(requestBody.delete)
this.seededJobs = this.seededJobs.filter(
({ listItem }) => !deletedIds.has(listItem.id)
)
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -14,8 +14,9 @@ export class KeyboardHelper {
keyToPress: string,
locator: Locator | null = this.canvas
): Promise<void> {
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
const target = locator ?? this.page.keyboard
await target.press(`Control+${keyToPress}`)
await target.press(`${modifier}+${keyToPress}`)
await this.nextFrame()
}

View File

@@ -1,28 +1,43 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Assets sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
await comfyPage.setup()
})
async function openAssetsSidebar(
comfyPage: ComfyPage,
seed: Parameters<ComfyPage['assets']['seedAssets']>[0]
) {
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'], {
origin: comfyPage.url
})
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.assets.seedAssets(seed)
const tab = comfyPage.menu.assetsTab
await tab.open()
return tab
}
test.describe('Assets sidebar', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Shows empty-state copy for generated and imported tabs', async ({
test('shows empty-state copy for generated and imported tabs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
const tab = await openAssetsSidebar(comfyPage, {
generated: [],
imported: []
})
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
await tab.importedTab.click()
await tab.showImported()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()

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>
@@ -143,11 +144,16 @@
<Button
v-if="shouldShowDeleteButton"
size="icon"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
@click="handleDeleteSelected"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button size="icon" @click="handleDownloadSelected">
<Button
size="icon"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
@click="handleDownloadSelected"
>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>

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" />