mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Compare commits
7 Commits
test/comfy
...
bl/assets-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cc1ca15dc | ||
|
|
c2d1c59b09 | ||
|
|
c0c0f00c24 | ||
|
|
3654bd7e08 | ||
|
|
fcb635511a | ||
|
|
268a804b58 | ||
|
|
920f4f8103 |
@@ -29,9 +29,6 @@ import {
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
|
||||
@@ -178,8 +175,6 @@ export class ComfyPage {
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly assetApi: AssetHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
@@ -229,8 +224,6 @@ export class ComfyPage {
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.assetApi = createAssetHelper(page)
|
||||
this.modelLibrary = new ModelLibraryHelper(page)
|
||||
}
|
||||
|
||||
@@ -452,7 +445,6 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
await comfyPage.assetApi.clearMocks()
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
|
||||
16
browser_tests/fixtures/assetApiFixture.ts
Normal file
16
browser_tests/fixtures/assetApiFixture.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import type { AssetHelper } from './helpers/AssetHelper'
|
||||
import { createAssetHelper } from './helpers/AssetHelper'
|
||||
|
||||
export const assetApiFixture = base.extend<{
|
||||
assetApi: AssetHelper
|
||||
}>({
|
||||
assetApi: async ({ page }, use) => {
|
||||
const assetApi = createAssetHelper(page)
|
||||
|
||||
await use(assetApi)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
}
|
||||
})
|
||||
15
browser_tests/fixtures/assetScenarioFixture.ts
Normal file
15
browser_tests/fixtures/assetScenarioFixture.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { AssetScenarioHelper } from './helpers/AssetScenarioHelper'
|
||||
|
||||
export const assetScenarioFixture = base.extend<{
|
||||
assetScenario: AssetScenarioHelper
|
||||
}>({
|
||||
assetScenario: async ({ page }, use) => {
|
||||
const assetScenario = new AssetScenarioHelper(page)
|
||||
|
||||
await use(assetScenario)
|
||||
|
||||
await assetScenario.clear()
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,10 @@ import { expect } from '@playwright/test'
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
class SidebarTab {
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
@@ -281,6 +285,10 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
get root() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
|
||||
get generatedTab() {
|
||||
@@ -299,30 +307,58 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
)
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
return this.root.getByPlaceholder(/Search Assets/i)
|
||||
}
|
||||
|
||||
get settingsButton() {
|
||||
return this.page.getByRole('button', { name: 'View settings' })
|
||||
return this.root.getByLabel('View settings')
|
||||
}
|
||||
|
||||
// --- View mode ---
|
||||
get viewSettingsButton() {
|
||||
return this.settingsButton
|
||||
}
|
||||
|
||||
get listViewOption() {
|
||||
return this.page.getByText('List view')
|
||||
}
|
||||
|
||||
get listViewButton() {
|
||||
return this.listViewOption
|
||||
}
|
||||
|
||||
get gridViewOption() {
|
||||
return this.page.getByText('Grid view')
|
||||
}
|
||||
|
||||
get gridViewButton() {
|
||||
return this.gridViewOption
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
previewImage(filename: string) {
|
||||
return this.previewDialog.getByRole('img', { name: filename })
|
||||
}
|
||||
|
||||
asset(name: string) {
|
||||
return this.getAssetCardByName(name)
|
||||
}
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
|
||||
get sortNewestFirst() {
|
||||
@@ -336,38 +372,38 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Asset cards ---
|
||||
|
||||
get assetCards() {
|
||||
return this.page.locator('[role="button"][data-selected]')
|
||||
return this.root.locator('[role="button"][data-selected]')
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
return this.root
|
||||
.getByRole('button', {
|
||||
name: new RegExp(`^${escapeRegExp(name)}\\s+-\\s+`)
|
||||
})
|
||||
.first()
|
||||
}
|
||||
|
||||
get selectedCards() {
|
||||
return this.page.locator('[data-selected="true"]')
|
||||
return this.root.locator('[data-selected="true"]')
|
||||
}
|
||||
|
||||
// --- List view items ---
|
||||
|
||||
get listViewItems() {
|
||||
return this.page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
return this.root.locator('[role="button"][tabindex="0"]')
|
||||
}
|
||||
|
||||
// --- Selection footer ---
|
||||
|
||||
get selectionFooter() {
|
||||
return this.page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
return this.root.locator('..').locator('[class*="h-18"]')
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.page.getByText(/Assets Selected: \d+/)
|
||||
return this.root
|
||||
.getByRole('button', { name: /Assets Selected:/ })
|
||||
.or(this.page.getByText(/Assets Selected: \d+/))
|
||||
.first()
|
||||
}
|
||||
|
||||
get deselectAllButton() {
|
||||
@@ -381,6 +417,10 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
.first()
|
||||
}
|
||||
|
||||
get deleteSelectionButton() {
|
||||
return this.deleteSelectedButton
|
||||
}
|
||||
|
||||
get downloadSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-download-selected')
|
||||
@@ -388,30 +428,117 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
.first()
|
||||
}
|
||||
|
||||
get downloadSelectionButton() {
|
||||
return this.downloadSelectedButton
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
contextMenuAction(label: string) {
|
||||
return this.contextMenuItem(label)
|
||||
}
|
||||
|
||||
// --- Folder view ---
|
||||
|
||||
get backToAssetsButton() {
|
||||
return this.page.getByText('Back to all assets')
|
||||
return this.backButton
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
return this.root.locator('.animate-pulse')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
async showGenerated() {
|
||||
await this.switchToGenerated()
|
||||
}
|
||||
|
||||
async showImported() {
|
||||
await this.switchToImported()
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
await this.searchInput.fill(query)
|
||||
}
|
||||
|
||||
async switchToListView() {
|
||||
await this.openSettingsMenu()
|
||||
await this.listViewOption.click()
|
||||
}
|
||||
|
||||
async switchToGridView() {
|
||||
await this.openSettingsMenu()
|
||||
await this.gridViewOption.click()
|
||||
}
|
||||
|
||||
async openContextMenuForAsset(name: string) {
|
||||
await this.asset(name).dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2
|
||||
})
|
||||
await this.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async runContextMenuAction(assetName: string, actionName: string) {
|
||||
await this.openContextMenuForAsset(assetName)
|
||||
await this.contextMenuAction(actionName).click()
|
||||
}
|
||||
|
||||
async openAssetPreview(name: string) {
|
||||
const asset = this.asset(name)
|
||||
await asset.hover()
|
||||
|
||||
const zoomButton = asset.getByLabel('Zoom in')
|
||||
if (await zoomButton.isVisible().catch(() => false)) {
|
||||
await zoomButton.click()
|
||||
return
|
||||
}
|
||||
|
||||
await asset.dblclick()
|
||||
}
|
||||
|
||||
async openOutputFolder(name: string) {
|
||||
await this.asset(name)
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
|
||||
await this.backToAssetsButton.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async toggleStack(name: string) {
|
||||
await this.asset(name)
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
}
|
||||
|
||||
async selectAssets(names: string[]) {
|
||||
if (names.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.asset(names[0]).click()
|
||||
|
||||
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
|
||||
for (const name of names.slice(1)) {
|
||||
await this.asset(name).click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.root.waitFor({ state: 'visible' })
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
@@ -455,7 +582,11 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
await card.dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2
|
||||
})
|
||||
await this.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
383
browser_tests/fixtures/helpers/AssetScenarioHelper.ts
Normal file
383
browser_tests/fixtures/helpers/AssetScenarioHelper.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
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 { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { buildMockJobOutputs } from './buildMockJobOutputs'
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture,
|
||||
ImportedAssetFixture
|
||||
} from './assetScenarioTypes'
|
||||
import { InMemoryJobsBackend } from './InMemoryJobsBackend'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
|
||||
const helperDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
type SeededAssetFile = {
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
textContent?: string
|
||||
}
|
||||
|
||||
type MockPreviewOutput = NonNullable<JobEntry['preview_output']> & {
|
||||
filename?: string
|
||||
subfolder?: string
|
||||
type?: GeneratedOutputFixture['type']
|
||||
nodeId: string
|
||||
mediaType?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
type SeededFileLocation = {
|
||||
filename: string
|
||||
type: string
|
||||
subfolder: string
|
||||
}
|
||||
|
||||
function getFixturePath(relativePath: string): string {
|
||||
return path.resolve(helperDir, '../../assets', relativePath)
|
||||
}
|
||||
|
||||
function buildSeededFileKey({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
}: SeededFileLocation): string {
|
||||
return new URLSearchParams({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
}).toString()
|
||||
}
|
||||
|
||||
function defaultFileFor(filename: string): SeededAssetFile {
|
||||
const normalized = filename.toLowerCase()
|
||||
|
||||
if (normalized.endsWith('.png')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow_itxt.png'),
|
||||
contentType: 'image/png'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.webp')) {
|
||||
return {
|
||||
filePath: getFixturePath('example.webp'),
|
||||
contentType: 'image/webp'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.webm')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.webm'),
|
||||
contentType: 'video/webm'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.mp4')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
|
||||
contentType: 'video/mp4'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.glb')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.glb'),
|
||||
contentType: 'model/gltf-binary'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.json')) {
|
||||
return {
|
||||
textContent: JSON.stringify({ mocked: true }, null, 2),
|
||||
contentType: 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
textContent: 'mocked asset content',
|
||||
contentType: getMimeType(filename)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutputFixture(
|
||||
output: GeneratedOutputFixture
|
||||
): GeneratedOutputFixture {
|
||||
const fallback = defaultFileFor(output.filename)
|
||||
|
||||
return {
|
||||
mediaType: 'images',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
...output,
|
||||
filePath: output.filePath ?? fallback.filePath,
|
||||
contentType: output.contentType ?? fallback.contentType
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputFilename(baseFilename: string, index: number): string {
|
||||
if (index === 0) {
|
||||
return baseFilename
|
||||
}
|
||||
|
||||
const extensionIndex = baseFilename.lastIndexOf('.')
|
||||
if (extensionIndex === -1) {
|
||||
return `${baseFilename}-${index + 1}`
|
||||
}
|
||||
|
||||
return `${baseFilename.slice(0, extensionIndex)}-${index + 1}${baseFilename.slice(extensionIndex)}`
|
||||
}
|
||||
|
||||
function getPreviewOutput(
|
||||
previewOutput: JobEntry['preview_output'] | undefined
|
||||
): MockPreviewOutput | undefined {
|
||||
return previewOutput as MockPreviewOutput | undefined
|
||||
}
|
||||
|
||||
function outputsFromJobEntry(
|
||||
job: JobEntry
|
||||
): [GeneratedOutputFixture, ...GeneratedOutputFixture[]] {
|
||||
const previewOutput = getPreviewOutput(job.preview_output)
|
||||
const outputCount = Math.max(job.outputs_count ?? 1, 1)
|
||||
const baseFilename = previewOutput?.filename ?? `output_${job.id}.png`
|
||||
const mediaType: GeneratedOutputFixture['mediaType'] =
|
||||
previewOutput?.mediaType === 'video' || previewOutput?.mediaType === 'audio'
|
||||
? previewOutput.mediaType
|
||||
: 'images'
|
||||
const outputs = Array.from({ length: outputCount }, (_, index) => ({
|
||||
filename: createOutputFilename(baseFilename, index),
|
||||
displayName: index === 0 ? previewOutput?.display_name : undefined,
|
||||
mediaType,
|
||||
subfolder: previewOutput?.subfolder ?? '',
|
||||
type: previewOutput?.type ?? 'output'
|
||||
}))
|
||||
|
||||
return [outputs[0], ...outputs.slice(1)]
|
||||
}
|
||||
|
||||
function generatedJobFromJobEntry(job: JobEntry): GeneratedJobFixture {
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
outputs: outputsFromJobEntry(job),
|
||||
createTime: job.create_time,
|
||||
executionStartTime: job.execution_start_time,
|
||||
executionEndTime: job.execution_end_time,
|
||||
workflowId: job.workflow_id
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeededJob(job: GeneratedJobFixture) {
|
||||
const outputs = job.outputs.map(normalizeOutputFixture)
|
||||
const preview = outputs[0]
|
||||
const createTime =
|
||||
job.createTime ??
|
||||
new Date(job.createdAt ?? '2026-03-27T12:00:00.000Z').getTime()
|
||||
const executionStartTime = job.executionStartTime ?? createTime
|
||||
const executionEndTime = job.executionEndTime ?? createTime + 2_000
|
||||
|
||||
const listItem: JobEntry = {
|
||||
id: job.jobId,
|
||||
status: job.status ?? 'completed',
|
||||
create_time: createTime,
|
||||
execution_start_time: executionStartTime,
|
||||
execution_end_time: executionEndTime,
|
||||
preview_output: {
|
||||
filename: preview.filename,
|
||||
subfolder: preview.subfolder ?? '',
|
||||
type: preview.type ?? 'output',
|
||||
nodeId: job.nodeId ?? '5',
|
||||
mediaType: preview.mediaType ?? 'images',
|
||||
display_name: preview.displayName
|
||||
},
|
||||
outputs_count: outputs.length,
|
||||
...(job.workflowId ? { workflow_id: job.workflowId } : {})
|
||||
}
|
||||
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
workflow: job.workflow,
|
||||
outputs: buildMockJobOutputs(job, outputs),
|
||||
update_time: executionEndTime
|
||||
}
|
||||
|
||||
return { listItem, detail }
|
||||
}
|
||||
|
||||
function outputLocation(output: GeneratedOutputFixture): SeededFileLocation {
|
||||
return {
|
||||
filename: output.filename,
|
||||
type: output.type ?? 'output',
|
||||
subfolder: output.subfolder ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function importedAssetLocation(
|
||||
asset: ImportedAssetFixture
|
||||
): SeededFileLocation {
|
||||
return {
|
||||
filename: asset.name,
|
||||
type: 'input',
|
||||
subfolder: ''
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetScenarioHelper {
|
||||
private readonly jobsBackend: InMemoryJobsBackend
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private generatedJobs: GeneratedJobFixture[] = []
|
||||
private importedFiles: ImportedAssetFixture[] = []
|
||||
private seededFiles = new Map<string, SeededAssetFile>()
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.jobsBackend = new InMemoryJobsBackend(page)
|
||||
}
|
||||
|
||||
async seedGeneratedHistory(jobs: readonly JobEntry[]): Promise<void> {
|
||||
await this.seed({
|
||||
generated: jobs.map(generatedJobFromJobEntry),
|
||||
imported: this.importedFiles
|
||||
})
|
||||
}
|
||||
|
||||
async seedImportedFiles(files: readonly string[]): Promise<void> {
|
||||
await this.seed({
|
||||
generated: this.generatedJobs,
|
||||
imported: files.map((name) => ({ name }))
|
||||
})
|
||||
}
|
||||
|
||||
async seedEmptyState(): Promise<void> {
|
||||
await this.seed({ generated: [], imported: [] })
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
this.seededFiles.clear()
|
||||
|
||||
await this.jobsBackend.clear()
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.viewRouteHandler) {
|
||||
await this.page.unroute(viewRoutePattern, this.viewRouteHandler)
|
||||
this.viewRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async seed({
|
||||
generated,
|
||||
imported
|
||||
}: {
|
||||
generated: GeneratedJobFixture[]
|
||||
imported: ImportedAssetFixture[]
|
||||
}): Promise<void> {
|
||||
this.generatedJobs = [...generated]
|
||||
this.importedFiles = [...imported]
|
||||
this.seededFiles = new Map()
|
||||
|
||||
for (const job of this.generatedJobs) {
|
||||
for (const output of job.outputs) {
|
||||
const fallback = defaultFileFor(output.filename)
|
||||
this.seededFiles.set(buildSeededFileKey(outputLocation(output)), {
|
||||
filePath: output.filePath ?? fallback.filePath,
|
||||
contentType: output.contentType ?? fallback.contentType,
|
||||
textContent: fallback.textContent
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of this.importedFiles) {
|
||||
const fallback = defaultFileFor(asset.name)
|
||||
this.seededFiles.set(buildSeededFileKey(importedAssetLocation(asset)), {
|
||||
filePath: asset.filePath ?? fallback.filePath,
|
||||
contentType: asset.contentType ?? fallback.contentType,
|
||||
textContent: fallback.textContent
|
||||
})
|
||||
}
|
||||
|
||||
await this.jobsBackend.seed(this.generatedJobs.map(buildSeededJob))
|
||||
await this.ensureInputFilesRoute()
|
||||
await this.ensureViewRoute()
|
||||
}
|
||||
|
||||
private async ensureInputFilesRoute(): Promise<void> {
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles.map((asset) => asset.name))
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
private async ensureViewRoute(): Promise<void> {
|
||||
if (this.viewRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.viewRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const filename = url.searchParams.get('filename')
|
||||
const type = url.searchParams.get('type') ?? 'output'
|
||||
const subfolder = url.searchParams.get('subfolder') ?? ''
|
||||
|
||||
if (!filename) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Missing filename' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const seededFile =
|
||||
this.seededFiles.get(
|
||||
buildSeededFileKey({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
})
|
||||
) ?? defaultFileFor(filename)
|
||||
|
||||
if (seededFile.filePath) {
|
||||
const body = await readFile(seededFile.filePath)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: seededFile.contentType ?? getMimeType(filename),
|
||||
body
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: seededFile.contentType ?? getMimeType(filename),
|
||||
body: seededFile.textContent ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(viewRoutePattern, this.viewRouteHandler)
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history$/
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const now = Date.now()
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<RawJobListItem>
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now()
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60_000,
|
||||
execution_start_time: now - i * 60_000,
|
||||
execution_end_time: now - i * 60_000 + 5000 + i * 1000,
|
||||
preview_output: {
|
||||
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return total
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: RawJobListItem): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
return end - start
|
||||
}
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private importedFiles: string[] = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
|
||||
this.generatedJobs = [...jobs]
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.jobsRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = [...this.generatedJobs]
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const total = filteredJobs.length
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the POST /api/history endpoint used for deleting history items.
|
||||
* On receiving a `{ delete: [id] }` payload, removes matching jobs from
|
||||
* the in-memory mock state so subsequent /api/jobs fetches reflect the
|
||||
* deletion.
|
||||
*/
|
||||
async mockDeleteHistory(): Promise<void> {
|
||||
if (this.deleteHistoryRouteHandler) return
|
||||
|
||||
this.deleteHistoryRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const body = request.postDataJSON() as { delete?: string[] }
|
||||
if (body.delete) {
|
||||
const idsToRemove = new Set(body.delete)
|
||||
this.generatedJobs = this.generatedJobs.filter(
|
||||
(job) => !idsToRemove.has(job.id)
|
||||
)
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 200, body: '{}' })
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.deleteHistoryRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyState(): Promise<void> {
|
||||
await this.mockOutputHistory([])
|
||||
await this.mockInputFiles([])
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.deleteHistoryRouteHandler) {
|
||||
await this.page.unroute(
|
||||
historyRoutePattern,
|
||||
this.deleteHistoryRouteHandler
|
||||
)
|
||||
this.deleteHistoryRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
207
browser_tests/fixtures/helpers/InMemoryJobsBackend.ts
Normal file
207
browser_tests/fixtures/helpers/InMemoryJobsBackend.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
|
||||
export type SeededJob = {
|
||||
listItem: JobEntry
|
||||
detail: JobDetailResponse
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return total
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: JobEntry): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
|
||||
return end - start
|
||||
}
|
||||
|
||||
function getJobIdFromRequest(route: Route): string | null {
|
||||
const url = new URL(route.request().url())
|
||||
const jobId = url.pathname.split('/').at(-1)
|
||||
|
||||
return jobId ? decodeURIComponent(jobId) : null
|
||||
}
|
||||
|
||||
export class InMemoryJobsBackend {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private seededJobs = new Map<string, SeededJob>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async seed(jobs: SeededJob[]): Promise<void> {
|
||||
this.seededJobs = new Map(
|
||||
jobs.map((job) => [job.listItem.id, job] satisfies [string, SeededJob])
|
||||
)
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.seededJobs.clear()
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = Array.from(
|
||||
this.seededJobs.values(),
|
||||
({ listItem }) => listItem
|
||||
)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const total = filteredJobs.length
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies JobsListResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = getJobIdFromRequest(route)
|
||||
const job = jobId ? this.seededJobs.get(jobId) : undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const requestBody = route.request().postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.seededJobs = new Map(
|
||||
Array.from(this.seededJobs).filter(([, job]) => {
|
||||
const status = job.listItem.status
|
||||
|
||||
return status === 'pending' || status === 'in_progress'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
for (const jobId of requestBody.delete) {
|
||||
this.seededJobs.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
32
browser_tests/fixtures/helpers/assetScenarioTypes.ts
Normal file
32
browser_tests/fixtures/helpers/assetScenarioTypes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { ResultItemType } from '../../../src/schemas/apiSchema'
|
||||
|
||||
export type ImportedAssetFixture = {
|
||||
name: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
export type GeneratedOutputFixture = {
|
||||
filename: string
|
||||
displayName?: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
mediaType?: 'images' | 'video' | 'audio'
|
||||
subfolder?: string
|
||||
type?: ResultItemType
|
||||
}
|
||||
|
||||
export type GeneratedJobFixture = {
|
||||
jobId: string
|
||||
status?: JobEntry['status']
|
||||
outputs: [GeneratedOutputFixture, ...GeneratedOutputFixture[]]
|
||||
createdAt?: string
|
||||
createTime?: number
|
||||
executionStartTime?: number
|
||||
executionEndTime?: number
|
||||
workflowId?: string
|
||||
workflow?: JobDetailResponse['workflow']
|
||||
nodeId?: string
|
||||
}
|
||||
28
browser_tests/fixtures/helpers/buildMockJobOutputs.ts
Normal file
28
browser_tests/fixtures/helpers/buildMockJobOutputs.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { JobDetailResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { TaskOutput } from '../../../src/schemas/apiSchema'
|
||||
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture
|
||||
} from './assetScenarioTypes'
|
||||
|
||||
export function buildMockJobOutputs(
|
||||
job: GeneratedJobFixture,
|
||||
outputs: GeneratedOutputFixture[]
|
||||
): NonNullable<JobDetailResponse['outputs']> {
|
||||
const nodeId = job.nodeId ?? '5'
|
||||
|
||||
const taskOutput = {
|
||||
[nodeId]: {
|
||||
[outputs[0].mediaType ?? 'images']: outputs.map((output) => ({
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder ?? '',
|
||||
type: output.type ?? 'output',
|
||||
display_name: output.displayName
|
||||
}))
|
||||
}
|
||||
} satisfies TaskOutput
|
||||
|
||||
return taskOutput
|
||||
}
|
||||
47
browser_tests/fixtures/helpers/jobFixtures.ts
Normal file
47
browser_tests/fixtures/helpers/jobFixtures.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
export function createMockJob(
|
||||
overrides: Partial<JobEntry> & { id: string }
|
||||
): JobEntry {
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<JobEntry>
|
||||
): JobEntry[] {
|
||||
const now = Date.now()
|
||||
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
createMockJob({
|
||||
id: `job-${String(index + 1).padStart(3, '0')}`,
|
||||
create_time: now - index * 60_000,
|
||||
execution_start_time: now - index * 60_000,
|
||||
execution_end_time: now - index * 60_000 + (5 + index) * 1_000,
|
||||
preview_output: {
|
||||
filename: `image_${String(index + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { assetApiFixture } from '../fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
createAssetHelper,
|
||||
withModels,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
STABLE_OUTPUT
|
||||
} from '../fixtures/data/assetFixtures'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetApiFixture)
|
||||
|
||||
test.describe('AssetHelper', () => {
|
||||
test.describe('operators and configuration', () => {
|
||||
test('creates helper with models via withModels operator', async ({
|
||||
@@ -66,8 +69,7 @@ test.describe('AssetHelper', () => {
|
||||
})
|
||||
|
||||
test.describe('mock API routes', () => {
|
||||
test('GET /assets returns all assets', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_INPUT_IMAGE)
|
||||
@@ -87,12 +89,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets).toHaveLength(2)
|
||||
expect(data.total).toBe(2)
|
||||
expect(data.has_more).toBe(false)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets respects pagination params', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets respects pagination params', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withModels(5),
|
||||
withPagination({ total: 10, hasMore: true })
|
||||
@@ -110,12 +112,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets).toHaveLength(2)
|
||||
expect(data.total).toBe(10)
|
||||
expect(data.has_more).toBe(true)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET /assets filters by include_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA),
|
||||
@@ -129,14 +131,12 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets).toHaveLength(1)
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -151,12 +151,12 @@ test.describe('AssetHelper', () => {
|
||||
`${comfyPage.url}/api/assets/nonexistent-id`
|
||||
)
|
||||
expect(notFound.status).toBe(404)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('PUT /assets/:id updates asset in store', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -175,14 +175,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
|
||||
'renamed.safetensors'
|
||||
)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('DELETE /assets/:id removes asset from store', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -193,11 +191,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(status).toBe(204)
|
||||
expect(assetApi.assetCount).toBe(1)
|
||||
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('POST /assets returns upload response', async ({ comfyPage }) => {
|
||||
test('POST /assets returns upload response', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const customUpload = {
|
||||
id: 'custom-upload-001',
|
||||
name: 'custom.safetensors',
|
||||
@@ -205,7 +204,6 @@ test.describe('AssetHelper', () => {
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
created_new: true
|
||||
}
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withUploadResponse(customUpload))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -217,14 +215,12 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { id: string; name: string }
|
||||
expect(data.id).toBe('custom-upload-001')
|
||||
expect(data.name).toBe('custom.safetensors')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('POST /assets/download returns async download response', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
await assetApi.mock()
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -235,14 +231,14 @@ test.describe('AssetHelper', () => {
|
||||
const data = body as { task_id: string; status: string }
|
||||
expect(data.task_id).toBe('download-task-001')
|
||||
expect(data.status).toBe('created')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('mutation tracking', () => {
|
||||
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('tracks POST, PUT, DELETE mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -265,12 +261,12 @@ test.describe('AssetHelper', () => {
|
||||
expect(mutations[0].method).toBe('POST')
|
||||
expect(mutations[1].method).toBe('PUT')
|
||||
expect(mutations[2].method).toBe('DELETE')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('GET requests are not tracked as mutations', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
@@ -280,14 +276,14 @@ test.describe('AssetHelper', () => {
|
||||
)
|
||||
|
||||
expect(assetApi.getMutations()).toHaveLength(0)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('mockError', () => {
|
||||
test('returns error status for all asset routes', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
test('returns error status for all asset routes', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
await assetApi.mockError(503, 'Service Unavailable')
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
@@ -296,16 +292,14 @@ test.describe('AssetHelper', () => {
|
||||
expect(status).toBe(503)
|
||||
const data = body as { error: string }
|
||||
expect(data.error).toBe('Service Unavailable')
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('clearMocks', () => {
|
||||
test('resets store, mutations, and unroutes handlers', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
@@ -35,16 +38,14 @@ const MOCK_JOBS: RawJobListItem[] = [
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(MOCK_JOBS)
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.Queue.QPOV2': false
|
||||
})
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '../../fixtures/helpers/AssetsHelper'
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
} from '@e2e/fixtures/helpers/jobFixtures'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
const SAMPLE_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1000,
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 1010,
|
||||
create_time: 1_000_000,
|
||||
execution_start_time: 1_000_000,
|
||||
execution_end_time: 1_010_000,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
@@ -28,9 +28,9 @@ const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-beta',
|
||||
create_time: 2000,
|
||||
execution_start_time: 2000,
|
||||
execution_end_time: 2003,
|
||||
create_time: 2_000_000,
|
||||
execution_start_time: 2_000_000,
|
||||
execution_end_time: 2_003_000,
|
||||
preview_output: {
|
||||
filename: 'portrait.png',
|
||||
subfolder: '',
|
||||
@@ -42,9 +42,9 @@ const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gamma',
|
||||
create_time: 3000,
|
||||
execution_start_time: 3000,
|
||||
execution_end_time: 3020,
|
||||
create_time: 3_000_000,
|
||||
execution_start_time: 3_000_000,
|
||||
execution_end_time: 3_020_000,
|
||||
preview_output: {
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
@@ -62,20 +62,47 @@ const SAMPLE_IMPORTED_FILES = [
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
async function installClipboardRecorder(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const scopedWindow = window as Window & {
|
||||
__assetClipboardWrites?: string[]
|
||||
}
|
||||
const writes: string[] = []
|
||||
|
||||
scopedWindow.__assetClipboardWrites = writes
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: async (text: string) => {
|
||||
writes.push(text)
|
||||
}
|
||||
} satisfies Pick<Clipboard, 'writeText'>
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function readClipboardWrites(page: Page): Promise<string[]> {
|
||||
return await page.evaluate(() => {
|
||||
const scopedWindow = window as Window & {
|
||||
__assetClipboardWrites?: string[]
|
||||
}
|
||||
|
||||
return scopedWindow.__assetClipboardWrites ?? []
|
||||
})
|
||||
}
|
||||
|
||||
function assetCardLabel(name: string): RegExp {
|
||||
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return new RegExp(`^${escapedName}\\s+-\\s+`)
|
||||
}
|
||||
|
||||
test.describe('Assets sidebar - empty states', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockEmptyState()
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedEmptyState()
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
@@ -101,21 +128,13 @@ test.describe('Assets sidebar - empty states', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Tab navigation
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - tab navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Generated tab is active by default', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
@@ -130,12 +149,10 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
@@ -144,31 +161,21 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Asset display - grid view
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - grid view display', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays generated assets as cards in grid view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -187,15 +194,13 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
test('Displays svg outputs', async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([
|
||||
test('Displays svg outputs', async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory([
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1000,
|
||||
@@ -219,31 +224,21 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. View mode toggle (grid <-> list)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - view mode toggle', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
@@ -252,35 +247,24 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Search functionality
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Search input is visible', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
@@ -297,10 +281,8 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect(async () => {
|
||||
const filteredCount = await tab.assetCards.count()
|
||||
expect(filteredCount).toBeLessThan(initialCount)
|
||||
@@ -314,7 +296,6 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(async () => {
|
||||
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
@@ -334,30 +315,20 @@ test.describe('Assets sidebar - search', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 6. Asset selection
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking an asset card selects it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -370,11 +341,9 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
@@ -386,10 +355,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
@@ -398,15 +365,12 @@ test.describe('Assets sidebar - selection', () => {
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
@@ -416,44 +380,31 @@ test.describe('Assets sidebar - selection', () => {
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 7. Context menu
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - context menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
@@ -547,23 +498,16 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
|
||||
await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
@@ -572,26 +516,17 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 8. Bulk actions (footer)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - bulk actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Footer shows download button when assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -601,7 +536,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
@@ -614,7 +548,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
@@ -623,7 +556,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select two assets
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
@@ -631,31 +563,147 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
await cards.first().click()
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
const text = await tab.selectionCountButton.textContent()
|
||||
expect(text).toMatch(/Assets Selected: \d+/)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - pagination', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
test.describe('Assets sidebar - preview and folder view', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await installClipboardRecorder(comfyPage.page)
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('initial load fetches first batch with offset 0', async ({
|
||||
test('opens a generated asset in the gallery dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openAssetPreview('landscape.png')
|
||||
|
||||
await expect(tab.previewDialog).toBeVisible()
|
||||
await expect(tab.previewImage('landscape.png')).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens a multi-output job folder and returns to all assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openOutputFolder('abstract_art.png')
|
||||
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
await expect(tab.copyJobIdButton).toBeVisible()
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
await expect(tab.asset('abstract_art-2.png')).toBeVisible()
|
||||
await expect(tab.asset('abstract_art.png')).toBeVisible()
|
||||
|
||||
await tab.backToAssetsButton.click()
|
||||
|
||||
await expect(tab.backToAssetsButton).toBeHidden()
|
||||
await expect(tab.assetCards).toHaveCount(SAMPLE_JOBS.length)
|
||||
await expect(
|
||||
tab.root.getByRole('button', {
|
||||
name: assetCardLabel('abstract_art-2.png')
|
||||
})
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('copies the folder job ID to the clipboard', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openOutputFolder('abstract_art.png')
|
||||
await tab.copyJobIdButton.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('.p-toast-message-success')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Job ID copied to clipboard')
|
||||
).toBeVisible()
|
||||
expect(await readClipboardWrites(comfyPage.page)).toEqual(['job-gamma'])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - delete confirmation', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('confirming delete removes the asset and shows success feedback', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
await tab.runContextMenuAction('portrait.png', 'Delete')
|
||||
|
||||
await expect(comfyPage.confirmDialog.root).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.confirmDialog.root.getByText('Delete this asset?')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.confirmDialog.root.getByText(
|
||||
'This asset will be permanently removed.'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(initialCount - 1, {
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(
|
||||
tab.root.getByRole('button', { name: assetCardLabel('portrait.png') })
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
comfyPage.page.locator('.p-toast-message-success')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Asset deleted successfully')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('cancelling delete preserves the asset', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
await tab.runContextMenuAction('portrait.png', 'Delete')
|
||||
await expect(comfyPage.confirmDialog.root).toBeVisible()
|
||||
|
||||
await comfyPage.confirmDialog.click('reject')
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(initialCount)
|
||||
await expect(tab.asset('portrait.png')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar - pagination', () => {
|
||||
test('initial load fetches first batch with offset 0', async ({
|
||||
comfyPage,
|
||||
assetScenario
|
||||
}) => {
|
||||
const manyJobs = createMockJobs(250)
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await assetScenario.seedGeneratedHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Capture the first history fetch (terminal statuses only).
|
||||
// Queue polling also hits /jobs but with status=in_progress,pending.
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
@@ -674,21 +722,13 @@ test.describe('Assets sidebar - pagination', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 10. Settings menu visibility
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - settings menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.seedGeneratedHistory(SAMPLE_JOBS)
|
||||
await assetScenario.seedImportedFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Settings menu shows view mode options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
@@ -699,83 +739,3 @@ test.describe('Assets sidebar - settings menu', () => {
|
||||
await expect(tab.gridViewOption).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 11. Delete confirmation
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - delete confirmation', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockDeleteHistory()
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Right-click delete shows confirmation dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Delete').click()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText('Delete this asset?')).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByText('This asset will be permanently removed.')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Confirming delete removes asset and shows success toast', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Delete').click()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await comfyPage.confirmDialog.delete.click()
|
||||
|
||||
await expect(dialog).not.toBeVisible()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount - 1, {
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
const successToast = comfyPage.page.locator('.p-toast-message-success')
|
||||
await expect(successToast).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Cancelling delete preserves asset', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Delete').click()
|
||||
|
||||
const dialog = comfyPage.confirmDialog.root
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await comfyPage.confirmDialog.reject.click()
|
||||
|
||||
await expect(dialog).not.toBeVisible()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
200
scripts/browser_tests/AssetScenarioHelper.test.ts
Normal file
200
scripts/browser_tests/AssetScenarioHelper.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AssetScenarioHelper } from '../../browser_tests/fixtures/helpers/AssetScenarioHelper'
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
ImportedAssetFixture
|
||||
} from '../../browser_tests/fixtures/helpers/assetScenarioTypes'
|
||||
|
||||
type RouteHandler = (route: Route) => Promise<void>
|
||||
|
||||
type RegisteredRoute = {
|
||||
pattern: string | RegExp
|
||||
handler: RouteHandler
|
||||
}
|
||||
|
||||
type PageStub = Pick<Page, 'route' | 'unroute'>
|
||||
|
||||
type AssetScenarioHelperTestAccess = {
|
||||
seed(args: {
|
||||
generated: GeneratedJobFixture[]
|
||||
imported: ImportedAssetFixture[]
|
||||
}): Promise<void>
|
||||
}
|
||||
|
||||
type FulfillOptions = NonNullable<Parameters<Route['fulfill']>[0]>
|
||||
|
||||
function createPageStub(): {
|
||||
page: PageStub
|
||||
routes: RegisteredRoute[]
|
||||
} {
|
||||
const routes: RegisteredRoute[] = []
|
||||
const page = {
|
||||
route: vi.fn(async (pattern: string | RegExp, handler: RouteHandler) => {
|
||||
routes.push({ pattern, handler })
|
||||
}),
|
||||
unroute: vi.fn(async () => {})
|
||||
} satisfies PageStub
|
||||
|
||||
return { page, routes }
|
||||
}
|
||||
|
||||
function getRouteHandler(
|
||||
routes: RegisteredRoute[],
|
||||
matcher: (pattern: string | RegExp) => boolean
|
||||
): RouteHandler {
|
||||
const registeredRoute = routes.find(({ pattern }) => matcher(pattern))
|
||||
|
||||
if (!registeredRoute) {
|
||||
throw new Error('Expected route handler to be registered')
|
||||
}
|
||||
|
||||
return registeredRoute.handler
|
||||
}
|
||||
|
||||
function createRouteInvocation(url: string): {
|
||||
route: Route
|
||||
getFulfilled: () => FulfillOptions | undefined
|
||||
} {
|
||||
let fulfilled: FulfillOptions | undefined
|
||||
|
||||
const route = {
|
||||
request: () =>
|
||||
({
|
||||
url: () => url
|
||||
}) as ReturnType<Route['request']>,
|
||||
fulfill: vi.fn(async (options?: FulfillOptions) => {
|
||||
if (!options) {
|
||||
throw new Error('Expected route to be fulfilled with options')
|
||||
}
|
||||
|
||||
fulfilled = options
|
||||
})
|
||||
} satisfies Pick<Route, 'request' | 'fulfill'>
|
||||
|
||||
return {
|
||||
route: route as unknown as Route,
|
||||
getFulfilled: () => fulfilled
|
||||
}
|
||||
}
|
||||
|
||||
function bodyToText(body: FulfillOptions['body']): string {
|
||||
if (body instanceof Uint8Array) {
|
||||
return Buffer.from(body).toString('utf-8')
|
||||
}
|
||||
|
||||
return `${body ?? ''}`
|
||||
}
|
||||
|
||||
async function invokeViewRoute(
|
||||
handler: RouteHandler,
|
||||
url: string
|
||||
): Promise<string> {
|
||||
const invocation = createRouteInvocation(url)
|
||||
|
||||
await handler(invocation.route)
|
||||
|
||||
const fulfilled = invocation.getFulfilled()
|
||||
expect(fulfilled).toBeDefined()
|
||||
|
||||
return bodyToText(fulfilled?.body)
|
||||
}
|
||||
|
||||
describe('AssetScenarioHelper', () => {
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
it('serves seeded files using filename, type, and subfolder together', async () => {
|
||||
tempDir = await mkdtemp(
|
||||
path.join(tmpdir(), 'asset-scenario-helper-view-route-')
|
||||
)
|
||||
|
||||
const outputFile = path.join(tempDir, 'output.txt')
|
||||
const nestedOutputFile = path.join(tempDir, 'nested-output.txt')
|
||||
const inputFile = path.join(tempDir, 'input.txt')
|
||||
|
||||
await Promise.all([
|
||||
writeFile(outputFile, 'root output'),
|
||||
writeFile(nestedOutputFile, 'nested output'),
|
||||
writeFile(inputFile, 'input asset')
|
||||
])
|
||||
|
||||
const { page, routes } = createPageStub()
|
||||
const helper = new AssetScenarioHelper(page as unknown as Page)
|
||||
const testAccess = helper as unknown as AssetScenarioHelperTestAccess
|
||||
|
||||
await testAccess.seed({
|
||||
generated: [
|
||||
{
|
||||
jobId: 'job-root',
|
||||
outputs: [
|
||||
{
|
||||
filename: 'shared-name.txt',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
filePath: outputFile,
|
||||
contentType: 'text/plain'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
jobId: 'job-nested',
|
||||
outputs: [
|
||||
{
|
||||
filename: 'shared-name.txt',
|
||||
type: 'output',
|
||||
subfolder: 'nested/folder',
|
||||
filePath: nestedOutputFile,
|
||||
contentType: 'text/plain'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
imported: [
|
||||
{
|
||||
name: 'shared-name.txt',
|
||||
filePath: inputFile,
|
||||
contentType: 'text/plain'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const viewRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
(pattern) =>
|
||||
pattern instanceof RegExp && /api\\\/view/.test(pattern.source)
|
||||
)
|
||||
|
||||
await expect(
|
||||
invokeViewRoute(
|
||||
viewRouteHandler,
|
||||
'http://localhost/api/view?filename=shared-name.txt&type=output&subfolder='
|
||||
)
|
||||
).resolves.toBe('root output')
|
||||
|
||||
await expect(
|
||||
invokeViewRoute(
|
||||
viewRouteHandler,
|
||||
'http://localhost/api/view?filename=shared-name.txt&type=output&subfolder=nested%2Ffolder'
|
||||
)
|
||||
).resolves.toBe('nested output')
|
||||
|
||||
await expect(
|
||||
invokeViewRoute(
|
||||
viewRouteHandler,
|
||||
'http://localhost/api/view?filename=shared-name.txt&type=input&subfolder='
|
||||
)
|
||||
).resolves.toBe('input asset')
|
||||
})
|
||||
})
|
||||
@@ -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,6 +144,7 @@
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
@@ -150,6 +152,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -23,8 +23,8 @@ const mockWorkflow: ComfyWorkflowJSON = {
|
||||
const mockJobDetailResponse: JobDetail = {
|
||||
id: 'test-job-id',
|
||||
status: 'completed',
|
||||
create_time: 1234567890,
|
||||
update_time: 1234567900,
|
||||
create_time: 1234567890000,
|
||||
update_time: 1234567900000,
|
||||
workflow: {
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
|
||||
Reference in New Issue
Block a user