mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 14:16:00 +00:00
Compare commits
5 Commits
bl/asset-s
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93959cb865 | ||
|
|
9e954a910e | ||
|
|
cb75f4e92d | ||
|
|
7a54e27397 | ||
|
|
5e8defb166 |
Binary file not shown.
|
Before Width: | Height: | Size: 516 B |
@@ -1,14 +0,0 @@
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
|
||||
|
||||
export const assetScenarioFixture = jobsApiMockFixture.extend<{
|
||||
assetScenario: AssetScenarioHelper
|
||||
}>({
|
||||
assetScenario: async ({ page, jobsApi }, use) => {
|
||||
const assetScenario = new AssetScenarioHelper(page, jobsApi)
|
||||
|
||||
await use(assetScenario)
|
||||
|
||||
await assetScenario.clear()
|
||||
}
|
||||
})
|
||||
@@ -4,10 +4,6 @@ import { expect } from '@playwright/test'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
class SidebarTab {
|
||||
public readonly tabButton: Locator
|
||||
public readonly selectedTabButton: Locator
|
||||
@@ -381,11 +377,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.assetCards.and(
|
||||
this.page.getByRole('button', {
|
||||
name: new RegExp(`^${escapeRegExp(name)}\\b`)
|
||||
})
|
||||
)
|
||||
return this.assetCards.filter({ hasText: name })
|
||||
}
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { buildMockJobOutputs } from '@e2e/fixtures/helpers/buildMockJobOutputs'
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture,
|
||||
ImportedAssetFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
import {
|
||||
buildFileRequestKey,
|
||||
buildMockAssetFiles,
|
||||
defaultFileFor
|
||||
} from '@e2e/fixtures/helpers/mockAssetFiles'
|
||||
import type { MockAssetFile } from '@e2e/fixtures/helpers/mockAssetFiles'
|
||||
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
|
||||
const DEFAULT_FIXTURE_CREATE_TIME = Date.UTC(2024, 0, 1, 0, 0, 0)
|
||||
|
||||
type InputFilesResponse = string[]
|
||||
type ViewErrorResponse = { error: string }
|
||||
|
||||
type MockPreviewOutput = NonNullable<JobEntry['preview_output']> & {
|
||||
filename?: string
|
||||
subfolder?: string
|
||||
type?: GeneratedOutputFixture['type']
|
||||
nodeId: string
|
||||
mediaType?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
function normalizeOutputFixture(
|
||||
output: GeneratedOutputFixture
|
||||
): GeneratedOutputFixture {
|
||||
const fallback = defaultFileFor(output.filename)
|
||||
|
||||
return {
|
||||
mediaType: 'images',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
...output,
|
||||
filePath: output.filePath ?? fallback.filePath,
|
||||
contentType: output.contentType ?? fallback.contentType
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputFilename(baseFilename: string, index: number): string {
|
||||
if (index === 0) {
|
||||
return baseFilename
|
||||
}
|
||||
|
||||
const extensionIndex = baseFilename.lastIndexOf('.')
|
||||
if (extensionIndex === -1) {
|
||||
return `${baseFilename}-${index + 1}`
|
||||
}
|
||||
|
||||
return `${baseFilename.slice(0, extensionIndex)}-${index + 1}${baseFilename.slice(extensionIndex)}`
|
||||
}
|
||||
|
||||
function getPreviewOutput(
|
||||
previewOutput: JobEntry['preview_output'] | undefined
|
||||
): MockPreviewOutput | undefined {
|
||||
return previewOutput as MockPreviewOutput | undefined
|
||||
}
|
||||
|
||||
function outputsFromJobEntry(
|
||||
job: JobEntry
|
||||
): [GeneratedOutputFixture, ...GeneratedOutputFixture[]] {
|
||||
const previewOutput = getPreviewOutput(job.preview_output)
|
||||
const outputCount = Math.max(job.outputs_count ?? 1, 1)
|
||||
const baseFilename = previewOutput?.filename ?? `output_${job.id}.png`
|
||||
const mediaType: GeneratedOutputFixture['mediaType'] =
|
||||
previewOutput?.mediaType === 'video' || previewOutput?.mediaType === 'audio'
|
||||
? previewOutput.mediaType
|
||||
: 'images'
|
||||
const outputs = Array.from({ length: outputCount }, (_, index) => ({
|
||||
filename: createOutputFilename(baseFilename, index),
|
||||
displayName: index === 0 ? previewOutput?.display_name : undefined,
|
||||
mediaType,
|
||||
subfolder: previewOutput?.subfolder ?? '',
|
||||
type: previewOutput?.type ?? 'output'
|
||||
}))
|
||||
|
||||
return [outputs[0], ...outputs.slice(1)]
|
||||
}
|
||||
|
||||
function generatedJobFromJobEntry(job: JobEntry): GeneratedJobFixture {
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
outputs: outputsFromJobEntry(job),
|
||||
createTime: job.create_time,
|
||||
executionStartTime: job.execution_start_time,
|
||||
executionEndTime: job.execution_end_time,
|
||||
workflowId: job.workflow_id
|
||||
}
|
||||
}
|
||||
|
||||
function buildMockJobRecord(job: GeneratedJobFixture) {
|
||||
const outputs = job.outputs.map(normalizeOutputFixture)
|
||||
const preview = outputs[0]
|
||||
const createTime =
|
||||
job.createTime ??
|
||||
(job.createdAt
|
||||
? new Date(job.createdAt).getTime()
|
||||
: DEFAULT_FIXTURE_CREATE_TIME)
|
||||
const executionStartTime = job.executionStartTime ?? createTime
|
||||
const executionEndTime = job.executionEndTime ?? createTime + 2_000
|
||||
|
||||
const listItem: JobEntry = {
|
||||
id: job.jobId,
|
||||
status: job.status ?? 'completed',
|
||||
create_time: createTime,
|
||||
execution_start_time: executionStartTime,
|
||||
execution_end_time: executionEndTime,
|
||||
preview_output: {
|
||||
filename: preview.filename,
|
||||
subfolder: preview.subfolder ?? '',
|
||||
type: preview.type ?? 'output',
|
||||
nodeId: job.nodeId ?? '5',
|
||||
mediaType: preview.mediaType ?? 'images',
|
||||
display_name: preview.displayName
|
||||
},
|
||||
outputs_count: outputs.length,
|
||||
...(job.workflowId ? { workflow_id: job.workflowId } : {})
|
||||
}
|
||||
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
workflow: job.workflow,
|
||||
outputs: buildMockJobOutputs(job, outputs),
|
||||
update_time: executionEndTime
|
||||
}
|
||||
|
||||
return { listItem, detail }
|
||||
}
|
||||
|
||||
export class AssetScenarioHelper {
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private generatedJobs: GeneratedJobFixture[] = []
|
||||
private importedFiles: ImportedAssetFixture[] = []
|
||||
private filesByRequestKey = new Map<string, MockAssetFile>()
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly jobsApi = new JobsApiMock(page)
|
||||
) {}
|
||||
|
||||
async mockGeneratedHistory(jobs: readonly JobEntry[]): Promise<void> {
|
||||
await this.mockScenario({
|
||||
generated: jobs.map(generatedJobFromJobEntry),
|
||||
imported: this.importedFiles
|
||||
})
|
||||
}
|
||||
|
||||
async mockImportedFiles(files: readonly string[]): Promise<void> {
|
||||
await this.mockScenario({
|
||||
generated: this.generatedJobs,
|
||||
imported: files.map((name) => ({ name }))
|
||||
})
|
||||
}
|
||||
|
||||
async mockEmptyState(): Promise<void> {
|
||||
await this.mockScenario({ generated: [], imported: [] })
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
this.filesByRequestKey.clear()
|
||||
|
||||
await this.jobsApi.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 mockScenario({
|
||||
generated,
|
||||
imported
|
||||
}: {
|
||||
generated: GeneratedJobFixture[]
|
||||
imported: ImportedAssetFixture[]
|
||||
}): Promise<void> {
|
||||
this.generatedJobs = [...generated]
|
||||
this.importedFiles = [...imported]
|
||||
this.filesByRequestKey = buildMockAssetFiles({
|
||||
generated: this.generatedJobs,
|
||||
imported: this.importedFiles
|
||||
})
|
||||
|
||||
await this.jobsApi.mockJobs(this.generatedJobs.map(buildMockJobRecord))
|
||||
await this.ensureInputFilesRoute()
|
||||
await this.ensureViewRoute()
|
||||
}
|
||||
|
||||
private async ensureInputFilesRoute(): Promise<void> {
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
const response = this.importedFiles.map(
|
||||
(asset) => asset.name
|
||||
) satisfies InputFilesResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
const response = {
|
||||
error: 'Missing filename'
|
||||
} satisfies ViewErrorResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const mockFile =
|
||||
this.filesByRequestKey.get(
|
||||
buildFileRequestKey({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
})
|
||||
) ?? defaultFileFor(filename)
|
||||
|
||||
if (mockFile.filePath) {
|
||||
const body = await readFile(mockFile.filePath)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: mockFile.contentType ?? getMimeType(filename),
|
||||
body
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: mockFile.contentType ?? getMimeType(filename),
|
||||
body: mockFile.textContent ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(viewRoutePattern, this.viewRouteHandler)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
|
||||
export type ImportedAssetFixture = {
|
||||
name: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
export type GeneratedOutputFixture = {
|
||||
filename: string
|
||||
displayName?: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
mediaType?: 'images' | 'video' | 'audio'
|
||||
subfolder?: string
|
||||
type?: ResultItemType
|
||||
}
|
||||
|
||||
export type GeneratedJobFixture = {
|
||||
jobId: string
|
||||
status?: JobEntry['status']
|
||||
outputs: [GeneratedOutputFixture, ...GeneratedOutputFixture[]]
|
||||
createdAt?: string
|
||||
createTime?: number
|
||||
executionStartTime?: number
|
||||
executionEndTime?: number
|
||||
workflowId?: string
|
||||
workflow?: JobDetailResponse['workflow']
|
||||
nodeId?: string
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { JobDetailResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { TaskOutput } from '@/schemas/apiSchema'
|
||||
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
|
||||
export function buildMockJobOutputs(
|
||||
job: GeneratedJobFixture,
|
||||
outputs: GeneratedOutputFixture[]
|
||||
): NonNullable<JobDetailResponse['outputs']> {
|
||||
const nodeId = job.nodeId ?? '5'
|
||||
const nodeOutputs: Pick<TaskOutput[string], 'audio' | 'images' | 'video'> = {}
|
||||
|
||||
for (const output of outputs) {
|
||||
const mediaType = output.mediaType ?? 'images'
|
||||
|
||||
nodeOutputs[mediaType] = [
|
||||
...(nodeOutputs[mediaType] ?? []),
|
||||
{
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder ?? '',
|
||||
type: output.type ?? 'output',
|
||||
display_name: output.displayName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const taskOutput = { [nodeId]: nodeOutputs } satisfies TaskOutput
|
||||
|
||||
return taskOutput
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture,
|
||||
ImportedAssetFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
const helperDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export type MockAssetFile = {
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
textContent?: string
|
||||
}
|
||||
|
||||
export type MockFileLocation = {
|
||||
filename: string
|
||||
type: string
|
||||
subfolder: string
|
||||
}
|
||||
|
||||
function getFixturePath(relativePath: string): string {
|
||||
return path.resolve(helperDir, '../../assets', relativePath)
|
||||
}
|
||||
|
||||
export function buildFileRequestKey({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
}: MockFileLocation): string {
|
||||
return new URLSearchParams({
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
}).toString()
|
||||
}
|
||||
|
||||
export function defaultFileFor(filename: string): MockAssetFile {
|
||||
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('.jpg') || normalized.endsWith('.jpeg')) {
|
||||
return {
|
||||
filePath: getFixturePath('example.jpg'),
|
||||
contentType: 'image/jpeg'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.webm')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.webm'),
|
||||
contentType: 'video/webm'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.mp4')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
|
||||
contentType: 'video/mp4'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.glb')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.glb'),
|
||||
contentType: 'model/gltf-binary'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.json')) {
|
||||
return {
|
||||
textContent: JSON.stringify({ mocked: true }, null, 2),
|
||||
contentType: 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
textContent: 'mocked asset content',
|
||||
contentType: getMimeType(filename)
|
||||
}
|
||||
}
|
||||
|
||||
function outputLocation(output: GeneratedOutputFixture): MockFileLocation {
|
||||
return {
|
||||
filename: output.filename,
|
||||
type: output.type ?? 'output',
|
||||
subfolder: output.subfolder ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function importedAssetLocation(asset: ImportedAssetFixture): MockFileLocation {
|
||||
return {
|
||||
filename: asset.name,
|
||||
type: 'input',
|
||||
subfolder: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMockAssetFiles({
|
||||
generated,
|
||||
imported
|
||||
}: {
|
||||
generated: readonly GeneratedJobFixture[]
|
||||
imported: readonly ImportedAssetFixture[]
|
||||
}): Map<string, MockAssetFile> {
|
||||
const mockFiles = new Map<string, MockAssetFile>()
|
||||
|
||||
for (const job of generated) {
|
||||
for (const output of job.outputs) {
|
||||
const fallback = defaultFileFor(output.filename)
|
||||
|
||||
mockFiles.set(buildFileRequestKey(outputLocation(output)), {
|
||||
filePath: output.filePath ?? fallback.filePath,
|
||||
contentType: output.contentType ?? fallback.contentType,
|
||||
textContent: fallback.textContent
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of imported) {
|
||||
const fallback = defaultFileFor(asset.name)
|
||||
|
||||
mockFiles.set(buildFileRequestKey(importedAssetLocation(asset)), {
|
||||
filePath: asset.filePath ?? fallback.filePath,
|
||||
contentType: asset.contentType ?? fallback.contentType,
|
||||
textContent: fallback.textContent
|
||||
})
|
||||
}
|
||||
|
||||
return mockFiles
|
||||
}
|
||||
104
browser_tests/tests/nodeOutputClearingOnRemove.spec.ts
Normal file
104
browser_tests/tests/nodeOutputClearingOnRemove.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function getNodeOutputImageCount(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<number> {
|
||||
return await comfyPage.page.evaluate(
|
||||
(id) => window.app!.nodeOutputs?.[id]?.images?.length ?? 0,
|
||||
nodeId
|
||||
)
|
||||
}
|
||||
|
||||
async function seedNodeOutput(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<void> {
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
window.app!.nodeOutputs[id] = {
|
||||
images: [
|
||||
{ filename: 'seeded-preview.png', subfolder: '', type: 'output' }
|
||||
]
|
||||
}
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
test.describe('Node output cleanup on removal', { tag: '@workflow' }, () => {
|
||||
test('Deleting a node clears its outputs from the store', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Pressing delete left previews behind because nodeOutputStore did not listen to onNodeRemoved'
|
||||
})
|
||||
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
expect(node).toBeTruthy()
|
||||
const nodeId = String(node.id)
|
||||
|
||||
await seedNodeOutput(comfyPage, nodeId)
|
||||
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(0)
|
||||
})
|
||||
|
||||
test('Undoing a node addition clears outputs produced for the removed node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Undo removed the node but left its preview rendered because the removal lifecycle did not invalidate nodeOutputStore'
|
||||
})
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
|
||||
const addedNodeId = await comfyPage.page.evaluate(() => {
|
||||
const litegraph = window.LiteGraph
|
||||
if (!litegraph) throw new Error('LiteGraph is not available on window')
|
||||
const graph = window.app!.graph
|
||||
const registered = litegraph.registered_node_types
|
||||
const typeName = registered['LoadImage']
|
||||
? 'LoadImage'
|
||||
: registered['PreviewImage']
|
||||
? 'PreviewImage'
|
||||
: undefined
|
||||
if (!typeName) {
|
||||
throw new Error('No suitable node type registered for the test')
|
||||
}
|
||||
const node = litegraph.createNode(typeName)
|
||||
if (!node) throw new Error('Failed to create test node')
|
||||
graph.add(node)
|
||||
return String(node.id)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
|
||||
await seedNodeOutput(comfyPage, addedNodeId)
|
||||
await expect
|
||||
.poll(() => getNodeOutputImageCount(comfyPage, addedNodeId))
|
||||
.toBe(1)
|
||||
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
await comfyPage.keyboard.undo()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount)
|
||||
await expect
|
||||
.poll(() => getNodeOutputImageCount(comfyPage, addedNodeId))
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,143 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/utils/jobFixtures'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
|
||||
|
||||
const GENERATED_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-landscape',
|
||||
create_time: 1_000_000,
|
||||
execution_start_time: 1_000_000,
|
||||
execution_end_time: 1_010_000,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-portrait',
|
||||
create_time: 2_000_000,
|
||||
execution_start_time: 2_000_000,
|
||||
execution_end_time: 2_008_000,
|
||||
preview_output: {
|
||||
filename: 'portrait.webp',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gallery',
|
||||
create_time: 3_000_000,
|
||||
execution_start_time: 3_000_000,
|
||||
execution_end_time: 3_015_000,
|
||||
preview_output: {
|
||||
filename: 'gallery.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 3
|
||||
})
|
||||
]
|
||||
|
||||
const IMPORTED_FILES = ['reference_photo.png', 'background.jpg', 'notes.txt']
|
||||
|
||||
test.describe('Assets sidebar browsing', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.mockGeneratedHistory(GENERATED_JOBS)
|
||||
await assetScenario.mockImportedFiles(IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('shows mocked generated and imported assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await expect(tab.getAssetCardByName('gallery.png')).toBeVisible()
|
||||
|
||||
await tab.switchToImported()
|
||||
await expect(tab.getAssetCardByName('reference_photo.png')).toBeVisible()
|
||||
})
|
||||
|
||||
test('clears search when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
|
||||
test('opens folder view for multi-output jobs and returns to all assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab
|
||||
.getAssetCardByName('gallery.png')
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: 'Copy job ID' })
|
||||
).toBeVisible()
|
||||
await expect(tab.getAssetCardByName('gallery-2.png')).toBeVisible()
|
||||
|
||||
await tab.backToAssetsButton.click()
|
||||
await expect(tab.getAssetCardByName('gallery.png')).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens the preview lightbox for generated assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.getAssetCardByName('landscape.png').dblclick()
|
||||
|
||||
await expect(comfyPage.mediaLightbox.root).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Assets sidebar empty states', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetScenario }) => {
|
||||
await assetScenario.mockEmptyState()
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('shows empty generated state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows empty imported state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -143,6 +143,7 @@ import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { installNodeOutputClearingHooks } from '@/composables/graph/useNodeOutputClearingHooks'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
@@ -249,11 +250,16 @@ const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
|
||||
// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
|
||||
let cleanupErrorHooks: (() => void) | null = null
|
||||
let cleanupNodeOutputHooks: (() => void) | null = null
|
||||
watch(
|
||||
() => canvasStore.currentGraph,
|
||||
(graph) => {
|
||||
cleanupErrorHooks?.()
|
||||
cleanupErrorHooks = graph ? installErrorClearingHooks(graph) : null
|
||||
cleanupNodeOutputHooks?.()
|
||||
cleanupNodeOutputHooks = graph
|
||||
? installNodeOutputClearingHooks(graph)
|
||||
: null
|
||||
}
|
||||
)
|
||||
|
||||
@@ -538,6 +544,9 @@ onMounted(async () => {
|
||||
// Install error-clearing hooks on the initial graph
|
||||
if (comfyApp.canvas?.graph) {
|
||||
cleanupErrorHooks = installErrorClearingHooks(comfyApp.canvas.graph)
|
||||
cleanupNodeOutputHooks = installNodeOutputClearingHooks(
|
||||
comfyApp.canvas.graph
|
||||
)
|
||||
}
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
@@ -597,6 +606,8 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
cleanupErrorHooks?.()
|
||||
cleanupErrorHooks = null
|
||||
cleanupNodeOutputHooks?.()
|
||||
cleanupNodeOutputHooks = null
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<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>
|
||||
|
||||
208
src/composables/graph/useNodeOutputClearingHooks.test.ts
Normal file
208
src/composables/graph/useNodeOutputClearingHooks.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installNodeOutputClearingHooks } from '@/composables/graph/useNodeOutputClearingHooks'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
function seedOutputForLocator(locatorId: string) {
|
||||
app.nodeOutputs[locatorId] = {
|
||||
images: [{ filename: 'preview.png', type: 'output', subfolder: '' }]
|
||||
}
|
||||
}
|
||||
|
||||
describe('installNodeOutputClearingHooks', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
app.nodeOutputs = {}
|
||||
app.nodePreviewImages = {}
|
||||
})
|
||||
|
||||
it('removes outputs for a root-level node when it is removed from the graph', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
|
||||
const locatorId = String(node.id)
|
||||
seedOutputForLocator(locatorId)
|
||||
expect(app.nodeOutputs[locatorId]).toBeDefined()
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
|
||||
expect(app.nodeOutputs[locatorId]).toBeUndefined()
|
||||
expect(useNodeOutputStore().nodeOutputs[locatorId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes outputs for a subgraph interior node using subgraphUuid:nodeId locator', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('LoadImage')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
|
||||
const interiorLocator = `${subgraph.id}:${interiorNode.id}`
|
||||
seedOutputForLocator(interiorLocator)
|
||||
expect(app.nodeOutputs[interiorLocator]).toBeDefined()
|
||||
|
||||
installNodeOutputClearingHooks(subgraph)
|
||||
subgraph.remove(interiorNode)
|
||||
|
||||
expect(app.nodeOutputs[interiorLocator]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not affect outputs for other nodes that remain in the graph', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const removed = new LGraphNode('LoadImage')
|
||||
const kept = new LGraphNode('LoadImage')
|
||||
graph.add(removed)
|
||||
graph.add(kept)
|
||||
|
||||
const removedLocator = String(removed.id)
|
||||
const keptLocator = String(kept.id)
|
||||
seedOutputForLocator(removedLocator)
|
||||
seedOutputForLocator(keptLocator)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(removed)
|
||||
|
||||
expect(app.nodeOutputs[removedLocator]).toBeUndefined()
|
||||
expect(app.nodeOutputs[keptLocator]).toBeDefined()
|
||||
})
|
||||
|
||||
it('chains with existing onNodeRemoved callbacks', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
let calledWith: LGraphNode | undefined
|
||||
graph.onNodeRemoved = (node) => {
|
||||
calledWith = node
|
||||
}
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
seedOutputForLocator(String(node.id))
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
|
||||
expect(calledWith).toBe(node)
|
||||
expect(app.nodeOutputs[String(node.id)]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores original onNodeRemoved when cleanup is called', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const original = () => undefined
|
||||
graph.onNodeRemoved = original
|
||||
|
||||
const cleanup = installNodeOutputClearingHooks(graph)
|
||||
expect(graph.onNodeRemoved).not.toBe(original)
|
||||
|
||||
cleanup()
|
||||
expect(graph.onNodeRemoved).toBe(original)
|
||||
})
|
||||
|
||||
it('clears interior node outputs when a subgraph container is removed from the root graph', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('LoadImage')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
|
||||
const subgraphNodeLocator = String(subgraphNode.id)
|
||||
const interiorLocator = `${subgraph.id}:${interiorNode.id}`
|
||||
seedOutputForLocator(subgraphNodeLocator)
|
||||
seedOutputForLocator(interiorLocator)
|
||||
|
||||
installNodeOutputClearingHooks(rootGraph)
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(app.nodeOutputs[subgraphNodeLocator]).toBeUndefined()
|
||||
expect(app.nodeOutputs[interiorLocator]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('also prunes the active workflow change tracker output cache so undo cannot resurrect the entry', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
const locator = String(node.id)
|
||||
seedOutputForLocator(locator)
|
||||
|
||||
const trackerCache: Record<string, unknown> = {
|
||||
[locator]: { images: [{ filename: 'preview.png' }] }
|
||||
}
|
||||
vi.spyOn(useWorkflowStore(), 'activeWorkflow', 'get').mockReturnValue({
|
||||
changeTracker: { nodeOutputs: trackerCache }
|
||||
} as never)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
|
||||
expect(app.nodeOutputs[locator]).toBeUndefined()
|
||||
expect(trackerCache[locator]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the tracker cache during workflow tab switch teardown', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
const locator = String(node.id)
|
||||
seedOutputForLocator(locator)
|
||||
|
||||
const trackerCache: Record<string, unknown> = {
|
||||
[locator]: { images: [{ filename: 'preview.png' }] }
|
||||
}
|
||||
vi.spyOn(useWorkflowStore(), 'activeWorkflow', 'get').mockReturnValue({
|
||||
changeTracker: { nodeOutputs: trackerCache, _restoringState: false }
|
||||
} as never)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
try {
|
||||
graph.remove(node)
|
||||
} finally {
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
}
|
||||
|
||||
expect(trackerCache[locator]).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not throw when the removal hook fires for an already-cleared node', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
const locator = String(node.id)
|
||||
seedOutputForLocator(locator)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
expect(() => graph.onNodeRemoved?.(node)).not.toThrow()
|
||||
expect(app.nodeOutputs[locator]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
68
src/composables/graph/useNodeOutputClearingHooks.ts
Normal file
68
src/composables/graph/useNodeOutputClearingHooks.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { getExecutionIdForNodeInGraph } from '@/utils/graphTraversalUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
function isTabSwitchTeardown(): boolean {
|
||||
const tracker = useWorkflowStore().activeWorkflow?.changeTracker
|
||||
return ChangeTracker.isLoadingGraph && !tracker?._restoringState
|
||||
}
|
||||
|
||||
function dropTrackerCacheEntry(execId: string) {
|
||||
if (isTabSwitchTeardown()) return
|
||||
const tracked = useWorkflowStore().activeWorkflow?.changeTracker?.nodeOutputs
|
||||
if (tracked) delete tracked[execId]
|
||||
}
|
||||
|
||||
function clearInteriorOutputs(
|
||||
subgraphNode: SubgraphNode,
|
||||
execIdPrefix: string
|
||||
) {
|
||||
const subgraph: Subgraph | undefined = subgraphNode.subgraph
|
||||
if (!subgraph) return
|
||||
|
||||
const store = useNodeOutputStore()
|
||||
for (const interior of subgraph.nodes) {
|
||||
store.removeOutputsByLocatorId(`${subgraph.id}:${interior.id}`)
|
||||
const interiorExecId = `${execIdPrefix}:${interior.id}`
|
||||
dropTrackerCacheEntry(interiorExecId)
|
||||
if (interior.isSubgraphNode()) {
|
||||
clearInteriorOutputs(interior, interiorExecId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function installNodeOutputClearingHooks(graph: LGraph): () => void {
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
|
||||
graph.onNodeRemoved = function (node: LGraphNode) {
|
||||
try {
|
||||
const store = useNodeOutputStore()
|
||||
const { nodeIdToNodeLocatorId } = useWorkflowStore()
|
||||
const locatorId = isSubgraph(graph)
|
||||
? nodeIdToNodeLocatorId(node.id, graph)
|
||||
: String(node.id)
|
||||
store.removeOutputsByLocatorId(locatorId)
|
||||
|
||||
const execId = app.rootGraph
|
||||
? getExecutionIdForNodeInGraph(app.rootGraph, graph, node.id)
|
||||
: String(node.id)
|
||||
dropTrackerCacheEntry(execId)
|
||||
|
||||
if (node.isSubgraphNode()) {
|
||||
clearInteriorOutputs(node, execId)
|
||||
}
|
||||
} finally {
|
||||
originalOnNodeRemoved?.call(this, node)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
}
|
||||
}
|
||||
@@ -499,6 +499,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
revokeSubgraphPreviews,
|
||||
removeNodeOutputs,
|
||||
removeNodeOutputsForNode,
|
||||
removeOutputsByLocatorId,
|
||||
snapshotOutputs,
|
||||
restoreOutputs,
|
||||
resetAllOutputsAndPreviews,
|
||||
|
||||
Reference in New Issue
Block a user