Compare commits

...

3 Commits

Author SHA1 Message Date
Benjamin Lu
6f81ea488d test: cover assets sidebar context menu behavior 2026-03-27 13:57:46 -07:00
Benjamin Lu
29b0618c5e test: cover core assets sidebar journeys 2026-03-27 13:57:15 -07:00
Benjamin Lu
adf17e516b test: add assets sidebar playwright foundation 2026-03-27 13:56:07 -07:00
7 changed files with 1054 additions and 116 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,30 +1,417 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import type { GeneratedJobSeed } from '../../fixtures/helpers/AssetsHelper'
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
}
function makeGeneratedAssets(comfyPage: ComfyPage) {
const stacked: GeneratedJobSeed = {
jobId: 'job-gallery-stack',
outputs: [
{
filename: 'gallery-main.webp',
displayName: 'Gallery Main',
mediaType: 'images'
},
{
filename: 'gallery-alt.webp',
displayName: 'Gallery Alt',
mediaType: 'images'
},
{
filename: 'gallery-detail.webp',
displayName: 'Gallery Detail',
mediaType: 'images'
}
]
}
return {
sunrise: comfyPage.assets.generatedImage({
jobId: 'job-sunrise',
filename: 'sunrise.webp',
displayName: 'Sunrise'
}),
forest: comfyPage.assets.generatedImage({
jobId: 'job-forest',
filename: 'forest.webp',
displayName: 'Forest'
}),
stacked
}
}
function makeImportedAssets(comfyPage: ComfyPage) {
return {
concept: comfyPage.assets.importedImage({
name: 'concept.png'
}),
reference: comfyPage.assets.importedImage({
name: 'reference.png'
})
}
}
async function makeWorkflowGeneratedAsset(comfyPage: ComfyPage) {
return comfyPage.assets.generatedImage({
jobId: 'job-workflow-sunrise',
filename: 'workflow-sunrise.webp',
displayName: 'Workflow Sunrise',
workflow: await comfyPage.assets.workflowContainerFromFixture()
})
}
test.describe('Assets sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
await comfyPage.setup()
})
test.describe.configure({ timeout: 30_000 })
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()
})
test('shows generated and imported assets, and clears search when switching tabs', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const imported = makeImportedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise, generated.forest],
imported: [imported.concept, imported.reference]
})
await expect(tab.asset('Sunrise')).toBeVisible()
await expect(tab.asset('Forest')).toBeVisible()
await tab.search('Sunrise')
await expect(tab.searchInput).toHaveValue('Sunrise')
await expect(tab.asset('Sunrise')).toBeVisible()
await expect(tab.asset('Forest')).not.toBeVisible()
await tab.showImported()
await expect(tab.searchInput).toHaveValue('')
await expect(tab.asset('concept.png')).toBeVisible()
await expect(tab.asset('reference.png')).toBeVisible()
})
test('opens preview from list view and shows the media dialog', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
await tab.switchToListView()
await tab.openAssetPreview('Sunrise')
await expect(tab.previewDialog).toBeVisible()
await expect(tab.previewImage('sunrise.webp')).toBeVisible()
await tab.previewDialog.getByLabel('Close').click()
await expect(tab.previewDialog).not.toBeVisible()
})
test('expands stacked outputs in list view', async ({ comfyPage }) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.stacked]
})
await tab.switchToListView()
await expect(tab.asset('Gallery Alt')).not.toBeVisible()
await tab.toggleStack('Gallery Main')
await expect(tab.asset('Gallery Alt')).toBeVisible()
await expect(tab.asset('Gallery Detail')).toBeVisible()
})
test('opens folder view for multi-output assets, copies the job ID, and returns back', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.stacked]
})
await tab.openOutputFolder('Gallery Main')
await expect(tab.backButton).toBeVisible()
await expect(tab.copyJobIdButton).toBeVisible()
await expect(tab.asset('Gallery Main')).toBeVisible()
await expect(tab.asset('Gallery Alt')).toBeVisible()
await expect(tab.asset('Gallery Detail')).toBeVisible()
await tab.copyJobIdButton.click()
await expect(comfyPage.visibleToasts).toContainText('Copied')
await expect(comfyPage.visibleToasts).toContainText(
'Job ID copied to clipboard'
)
await tab.searchInput.click()
await comfyPage.clipboard.paste(tab.searchInput)
await expect(tab.searchInput).toHaveValue(generated.stacked.jobId)
await tab.backButton.click()
await expect(tab.asset('Gallery Main')).toBeVisible()
await expect(tab.asset('Gallery Alt')).not.toBeVisible()
await expect(tab.asset('Gallery Detail')).not.toBeVisible()
})
test('shows output asset context-menu actions and can delete an asset', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise, generated.forest]
})
await tab.openContextMenuForAsset('Sunrise')
await expect(tab.contextMenuAction('Inspect asset')).toBeVisible()
await expect(
tab.contextMenuAction('Insert as node in workflow')
).toBeVisible()
await expect(tab.contextMenuAction('Download')).toBeVisible()
await expect(
tab.contextMenuAction('Open as workflow in new tab')
).toBeVisible()
await expect(tab.contextMenuAction('Export workflow')).toBeVisible()
await expect(tab.contextMenuAction('Copy job ID')).toBeVisible()
await expect(tab.contextMenuAction('Delete')).toBeVisible()
await tab.contextMenuAction('Delete').click()
await comfyPage.confirmDialog.click('delete')
await expect(tab.asset('Sunrise')).not.toBeVisible()
await expect(tab.asset('Forest')).toBeVisible()
})
test('opens preview from the output asset context menu', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
await tab.runContextMenuAction('Sunrise', 'Inspect asset')
await expect(tab.previewDialog).toBeVisible()
await expect(tab.previewImage('sunrise.webp')).toBeVisible()
})
test('downloads an output asset from the context menu', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
const downloadPromise = comfyPage.page.waitForEvent('download')
await tab.runContextMenuAction('Sunrise', 'Download')
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('Sunrise')
})
test('copies an output asset job ID from the context menu', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
await tab.runContextMenuAction('Sunrise', 'Copy job ID')
await expect(comfyPage.visibleToasts).toContainText('Copied to clipboard')
await tab.searchInput.click()
await comfyPage.clipboard.paste(tab.searchInput)
await expect(tab.searchInput).toHaveValue(generated.sunrise.jobId)
})
test('inserts an output asset into the workflow from the context menu', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
await tab.runContextMenuAction('Sunrise', 'Insert as node in workflow')
await comfyPage.vueNodes.waitForNodes()
await expect(comfyPage.vueNodes.getNodeByTitle('Load Image')).toBeVisible()
})
test('opens a workflow from the output asset context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
const workflowAsset = await makeWorkflowGeneratedAsset(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [workflowAsset]
})
await tab.runContextMenuAction(
'Workflow Sunrise',
'Open as workflow in new tab'
)
await expect(comfyPage.visibleToasts).toContainText(
'Workflow opened in new tab'
)
const workflowsTab = comfyPage.menu.workflowsTab
await workflowsTab.open()
await expect
.poll(async () => {
return (await workflowsTab.getOpenedWorkflowNames()).some((name) =>
name.includes('workflow-sunrise')
)
})
.toBe(true)
})
test('exports a workflow from the output asset context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.PromptFilename', false)
const workflowAsset = await makeWorkflowGeneratedAsset(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [workflowAsset]
})
const downloadPromise = comfyPage.page.waitForEvent('download')
await tab.runContextMenuAction('Workflow Sunrise', 'Export workflow')
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('workflow-sunrise.json')
await expect(comfyPage.visibleToasts).toContainText(
'Workflow exported successfully'
)
})
test('shows imported asset context-menu actions without output-only actions, and can insert the asset into the workflow', async ({
comfyPage
}) => {
const imported = makeImportedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
imported: [imported.concept]
})
await tab.showImported()
await tab.openContextMenuForAsset('concept.png')
await expect(
tab.contextMenuAction('Insert as node in workflow')
).toBeVisible()
await expect(tab.contextMenuAction('Download')).toBeVisible()
await expect(
tab.contextMenuAction('Open as workflow in new tab')
).toBeVisible()
await expect(tab.contextMenuAction('Export workflow')).toBeVisible()
await expect(tab.contextMenuAction('Copy job ID')).not.toBeVisible()
await expect(tab.contextMenuAction('Delete')).not.toBeVisible()
await tab.contextMenuAction('Insert as node in workflow').click()
await comfyPage.vueNodes.waitForNodes()
await expect(comfyPage.vueNodes.getNodeByTitle('Load Image')).toBeVisible()
})
test('shows the selection footer, can clear the selection, and can download a selected asset', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise, generated.forest]
})
await tab.selectAssets(['Sunrise', 'Forest'])
await expect(tab.selectionCountButton).toBeVisible()
await expect(tab.selectionCountButton).toContainText('Assets Selected: 2')
await tab.selectionCountButton.click()
await expect(tab.selectionCountButton).not.toBeVisible()
await tab.selectAssets(['Sunrise'])
const downloadPromise = comfyPage.page.waitForEvent('download')
await tab.downloadSelectionButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('Sunrise')
await expect(tab.selectionCountButton).not.toBeVisible()
})
test('clears the current selection when switching tabs', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const imported = makeImportedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise],
imported: [imported.concept]
})
await tab.selectAssets(['Sunrise'])
await expect(tab.selectionCountButton).toBeVisible()
await tab.showImported()
await expect(tab.selectionCountButton).not.toBeVisible()
await expect(tab.asset('concept.png')).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" />