mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-05 03:59:09 +00:00
Compare commits
3 Commits
dev/remote
...
bl/assets-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f81ea488d | ||
|
|
29b0618c5e | ||
|
|
adf17e516b |
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
194
browser_tests/fixtures/helpers/JobsApiMock.ts
Normal file
194
browser_tests/fixtures/helpers/JobsApiMock.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user