mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-04 19:49:09 +00:00
Compare commits
7 Commits
test/error
...
test/tier2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e0e8534d | ||
|
|
aeafff1ead | ||
|
|
f4fb7a458e | ||
|
|
71a3bd92b4 | ||
|
|
b6038b071e | ||
|
|
0f742ccab9 | ||
|
|
0dbaf533b5 |
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [500, 100],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "UNKNOWN NODE",
|
||||
"pos": [48, 86],
|
||||
"size": [358, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [],
|
||||
"slot_index": 0,
|
||||
"shape": 6
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "UNKNOWN NODE"
|
||||
},
|
||||
"widgets_values": ["wd-v1-4-moat-tagger-v2", 0.35, 0.85, false, false, ""]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [450, 86],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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(?:\?.*)?$/
|
||||
@@ -9,12 +11,12 @@ const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const now = Date.now() / 1000
|
||||
const now = Date.now()
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5,
|
||||
execution_end_time: now + 5000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
@@ -33,13 +35,13 @@ export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<RawJobListItem>
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now() / 1000
|
||||
const now = Date.now()
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60,
|
||||
execution_start_time: now - i * 60,
|
||||
execution_end_time: now - i * 60 + 5 + i,
|
||||
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: '',
|
||||
@@ -143,18 +145,24 @@ export class AssetsHelper {
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
// Response shape matches JobsListResponse from @comfyorg/ingest-types
|
||||
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({
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
})
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -41,21 +41,10 @@ export const TestIds = {
|
||||
missingNodeCard: 'missing-node-card',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
errorDialog: 'error-dialog',
|
||||
errorDialogShowReport: 'error-dialog-show-report',
|
||||
errorDialogContactSupport: 'error-dialog-contact-support',
|
||||
errorDialogCopyReport: 'error-dialog-copy-report',
|
||||
errorDialogFindIssues: 'error-dialog-find-issues',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingModelExpand: 'missing-model-expand',
|
||||
missingModelLocate: 'missing-model-locate',
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export async function interceptClipboardWrite(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const w = window as Window & { __copiedText?: string }
|
||||
w.__copiedText = ''
|
||||
navigator.clipboard.writeText = async (text: string) => {
|
||||
w.__copiedText = text
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function getClipboardText(page: Page): Promise<string> {
|
||||
return (
|
||||
(await page.evaluate(
|
||||
() => (window as Window & { __copiedText?: string }).__copiedText
|
||||
)) ?? ''
|
||||
)
|
||||
}
|
||||
@@ -3,11 +3,261 @@ import { expect } from '@playwright/test'
|
||||
import type { Keybinding } from '../../src/platform/keybindings/types'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show error overlay when loading a workflow with missing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/missing.*installed/i)
|
||||
})
|
||||
|
||||
test('Should show error overlay when loading a workflow with missing nodes in subgraphs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/missing.*installed/i)
|
||||
|
||||
// Click "See Errors" to open the errors tab and verify subgraph node content
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
|
||||
// Expand the pack group row to reveal node type names
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab when clicking See Errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the right side panel errors tab
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify MissingNodeCard is rendered in the errors tab
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Does not resurface missing nodes on undo/redo', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the error overlay
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Make a change to the graph by moving a node
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(450, 350, { steps: 5 })
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Undo and redo should not resurface the error overlay
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test.describe('Execution error', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Should display an error message when an execution error occurs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for the error overlay to be visible
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show Find on GitHub and Copy buttons in error card after execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for error overlay and click "See Errors"
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify Find on GitHub button is present in the error card
|
||||
const findOnGithubButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorCardFindOnGithub
|
||||
)
|
||||
await expect(findOnGithubButton).toBeVisible()
|
||||
|
||||
// Verify Copy button is present in the error card
|
||||
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
|
||||
await expect(copyButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing models in Error Tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
expect(cleanupOk).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Should show error overlay with missing models when workflow has missing models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/required model.*missing/i)
|
||||
})
|
||||
|
||||
test('Should show missing models from node properties', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/required model.*missing/i)
|
||||
})
|
||||
|
||||
test('Should not show missing models when widget values have changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/model_metadata_widget_mismatch'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Settings', () => {
|
||||
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
@@ -129,6 +379,38 @@ test.describe('Support', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Error dialog', () => {
|
||||
test('Should display an error dialog when graph configure fails', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph!
|
||||
;(graph as { configure: () => void }).configure = () => {
|
||||
throw new Error('Error on configure!')
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const errorDialog = comfyPage.page.locator('.comfy-error-report')
|
||||
await expect(errorDialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display an error dialog when prompt execution fails', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const app = window.app!
|
||||
app.api.queuePrompt = () => {
|
||||
throw new Error('Error on queuePrompt!')
|
||||
}
|
||||
await app.queuePrompt(0)
|
||||
})
|
||||
const errorDialog = comfyPage.page.locator('.comfy-error-report')
|
||||
await expect(errorDialog).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Signin dialog', () => {
|
||||
test('Paste content to signin dialog should not paste node on canvas', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import {
|
||||
interceptClipboardWrite,
|
||||
getClipboardText
|
||||
} from '../helpers/clipboardSpy'
|
||||
|
||||
async function triggerConfigureError(
|
||||
comfyPage: ComfyPage,
|
||||
message = 'Error on configure!'
|
||||
) {
|
||||
await comfyPage.page.evaluate((msg: string) => {
|
||||
const graph = window.graph!
|
||||
;(graph as { configure: () => void }).configure = () => {
|
||||
throw new Error(msg)
|
||||
}
|
||||
}, message)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
|
||||
}
|
||||
|
||||
async function waitForPopupNavigation(page: Page, action: () => Promise<void>) {
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
await action()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState()
|
||||
return popup
|
||||
}
|
||||
|
||||
test.describe('Error dialog', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Should display an error dialog when graph configure fails', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const errorDialog = await triggerConfigureError(comfyPage)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display an error dialog when prompt execution fails', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const app = window.app!
|
||||
app.api.queuePrompt = () => {
|
||||
throw new Error('Error on queuePrompt!')
|
||||
}
|
||||
await app.queuePrompt(0)
|
||||
})
|
||||
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display error message body', async ({ comfyPage }) => {
|
||||
const errorDialog = await triggerConfigureError(
|
||||
comfyPage,
|
||||
'Test error message body'
|
||||
)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
await expect(errorDialog).toContainText('Test error message body')
|
||||
})
|
||||
|
||||
test('Should show report section when "Show Report" is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const errorDialog = await triggerConfigureError(comfyPage)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
await expect(errorDialog.locator('pre')).not.toBeVisible()
|
||||
|
||||
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
|
||||
|
||||
const reportPre = errorDialog.locator('pre')
|
||||
await expect(reportPre).toBeVisible()
|
||||
await expect(reportPre).toHaveText(/\S/)
|
||||
await expect(
|
||||
errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Should copy report to clipboard when "Copy to Clipboard" is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const errorDialog = await triggerConfigureError(comfyPage)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
|
||||
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
|
||||
await expect(errorDialog.locator('pre')).toBeVisible()
|
||||
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
await errorDialog.getByTestId(TestIds.dialogs.errorDialogCopyReport).click()
|
||||
|
||||
const reportText = await errorDialog.locator('pre').textContent()
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toBe(reportText)
|
||||
})
|
||||
|
||||
test('Should open GitHub issues search when "Find Issues" is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const errorDialog = await triggerConfigureError(comfyPage)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
|
||||
const popup = await waitForPopupNavigation(comfyPage.page, () =>
|
||||
errorDialog.getByTestId(TestIds.dialogs.errorDialogFindIssues).click()
|
||||
)
|
||||
|
||||
const url = new URL(popup.url())
|
||||
expect(url.hostname).toBe('github.com')
|
||||
expect(url.pathname).toContain('/issues')
|
||||
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
test('Should open contact support when "Help Fix This" is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const errorDialog = await triggerConfigureError(comfyPage)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
|
||||
const popup = await waitForPopupNavigation(comfyPage.page, () =>
|
||||
errorDialog.getByTestId(TestIds.dialogs.errorDialogContactSupport).click()
|
||||
)
|
||||
|
||||
const url = new URL(popup.url())
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
|
||||
await popup.close()
|
||||
})
|
||||
})
|
||||
@@ -1,195 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
function getOverlay(page: Page) {
|
||||
return page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
function getSeeErrorsButton(page: Page) {
|
||||
return getOverlay(page).getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
}
|
||||
|
||||
test.describe('Labels', () => {
|
||||
test('Should display singular error count label for single error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getOverlay(comfyPage.page)).toContainText(/1 ERROR/i)
|
||||
})
|
||||
|
||||
test('Should display "Show missing nodes" button for missing node errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/Show missing nodes/i
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display "Show missing models" button for missing model errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
expect(cleanupOk).toBeTruthy()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/Show missing models/i
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display "Show missing inputs" button for missing media errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/Show missing inputs/i
|
||||
)
|
||||
})
|
||||
|
||||
test('Should display generic "See Errors" button for multiple error types', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_and_media')
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
|
||||
/See Errors/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Persistence', () => {
|
||||
test('Does not resurface missing nodes on undo/redo', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = getOverlay(comfyPage.page)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(450, 350, { steps: 5 })
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('See Errors flow', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function triggerExecutionError(comfyPage: {
|
||||
canvasOps: { disconnectEdge: () => Promise<void> }
|
||||
page: Page
|
||||
command: { executeCommand: (cmd: string) => Promise<void> }
|
||||
}) {
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
}
|
||||
|
||||
test('Error overlay appears on execution error', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
await expect(getOverlay(comfyPage.page)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Error overlay shows error message', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('"Dismiss" closes overlay without opening panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId('properties-panel')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /close/i }).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
93
browser_tests/tests/errorOverlaySeeErrors.spec.ts
Normal file
93
browser_tests/tests/errorOverlaySeeErrors.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function triggerExecutionError(comfyPage: {
|
||||
canvasOps: { disconnectEdge: () => Promise<void> }
|
||||
page: Page
|
||||
command: { executeCommand: (cmd: string) => Promise<void> }
|
||||
}) {
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
}
|
||||
|
||||
test('Error overlay appears on execution error', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Error overlay shows error message', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('"Dismiss" closes overlay without opening panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId('properties-panel')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /close/i }).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,21 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import { openErrorsTabViaSeeErrors } from './ErrorsTabHelper'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
async function loadMissingMediaAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow = 'missing/missing_media_single'
|
||||
) {
|
||||
await comfyPage.workflow.loadWorkflow(workflow)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
}
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
@@ -36,7 +48,7 @@ function getDropzone(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test.describe('Missing media inputs in Error Tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -46,8 +58,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test.describe('Detection', () => {
|
||||
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
test('Shows error overlay when workflow has missing media inputs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/missing required inputs/i)
|
||||
})
|
||||
|
||||
test('Shows missing media group in errors tab after clicking See Errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
@@ -57,7 +88,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Shows correct number of missing media rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadMissingMediaAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_multiple'
|
||||
)
|
||||
@@ -68,20 +99,30 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Shows upload dropzone and library select for each missing item', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Does not show error overlay when all media inputs exist', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Upload flow', () => {
|
||||
test.describe('Upload flow (2-step confirm)', () => {
|
||||
test('Upload via file picker shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
@@ -91,11 +132,11 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Library select flow', () => {
|
||||
test.describe('Library select flow (2-step confirm)', () => {
|
||||
test('Selecting from library shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
const librarySelect = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLibrarySelect
|
||||
@@ -121,7 +162,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Cancelling pending selection returns to upload/library UI', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
@@ -140,7 +181,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Missing Inputs group disappears when all items are resolved', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
await confirmPendingSelection(comfyPage)
|
||||
|
||||
@@ -154,7 +195,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Locate button navigates canvas to the missing media node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
@@ -1,17 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
export async function openErrorsTabViaSeeErrors(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: string
|
||||
) {
|
||||
await comfyPage.workflow.loadWorkflow(workflow)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
}
|
||||
@@ -1,73 +1,31 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
|
||||
|
||||
test.describe('Errors tab - common', { tag: '@ui' }, () => {
|
||||
test.describe('Properties panel - Errors tab', () => {
|
||||
let panel: PropertiesPanelHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test('should show Errors tab when errors exist', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(panel.errorsTabIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Tab visibility', () => {
|
||||
test('Should show Errors tab when errors exist', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await expect(panel.errorsTabIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not show Errors tab when setting is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
false
|
||||
)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await expect(panel.errorsTabIcon).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search and filter', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Should filter execution errors by search query', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
|
||||
const runtimePanel = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.runtimeErrorPanel
|
||||
)
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
|
||||
await searchInput.fill('nonexistent_query_xyz_12345')
|
||||
|
||||
await expect(runtimePanel).not.toBeVisible()
|
||||
})
|
||||
test('should not show Errors tab when errors are disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(panel.errorsTabIcon).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { ComfyPage } from '../../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function openExecutionErrorTab(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
}
|
||||
|
||||
test('Should show Find on GitHub and Copy buttons in error card', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openExecutionErrorTab(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorCardFindOnGithub)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show error message in runtime error panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openExecutionErrorTab(comfyPage)
|
||||
|
||||
const runtimePanel = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.runtimeErrorPanel
|
||||
)
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
await expect(runtimePanel).toContainText(/\S/)
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import {
|
||||
interceptClipboardWrite,
|
||||
getClipboardText
|
||||
} from '../../helpers/clipboardSpy'
|
||||
import { openErrorsTabViaSeeErrors } from './ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
expect(cleanupOk).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Should show missing models group in errors tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display model name with referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
|
||||
})
|
||||
|
||||
test('Should expand model row to show referencing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
comfyPage,
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelLocate
|
||||
)
|
||||
await expect(locateButton.first()).not.toBeVisible()
|
||||
|
||||
const expandButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelExpand
|
||||
)
|
||||
await expect(expandButton.first()).toBeVisible()
|
||||
await expandButton.first().click()
|
||||
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should copy model name to clipboard', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const copyButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyName
|
||||
)
|
||||
await expect(copyButton.first()).toBeVisible()
|
||||
await copyButton.first().click()
|
||||
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toContain('fake_model.safetensors')
|
||||
})
|
||||
|
||||
test.describe('OSS-specific', { tag: '@oss' }, () => {
|
||||
test('Should show Copy URL button for non-asset models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show Download button for downloadable models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
|
||||
const downloadButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelDownload
|
||||
)
|
||||
await expect(downloadButton.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import { openErrorsTabViaSeeErrors } from './ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should collapse expanded pack group', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).toBeVisible()
|
||||
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /collapse/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(
|
||||
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Locate node button is visible for expanded pack nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
await missingNodeCard
|
||||
.getByRole('button', { name: /expand/i })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const locateButton = missingNodeCard.getByRole('button', {
|
||||
name: /locate/i
|
||||
})
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
// TODO: Add navigation assertion once subgraph node ID deduplication
|
||||
// timing is fixed. Currently, collectMissingNodes runs before
|
||||
// configure(), so execution IDs use pre-remapped node IDs that don't
|
||||
// match the runtime graph. See PR #9510 / #8762.
|
||||
})
|
||||
})
|
||||
118
browser_tests/tests/queue/queueOverlay.spec.ts
Normal file
118
browser_tests/tests/queue/queueOverlay.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { createMockJob } from '../../fixtures/helpers/AssetsHelper'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
create_time: now - 60_000,
|
||||
execution_start_time: now - 60_000,
|
||||
execution_end_time: now - 50_000,
|
||||
outputs_count: 2
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-completed-2',
|
||||
status: 'completed',
|
||||
create_time: now - 120_000,
|
||||
execution_start_time: now - 120_000,
|
||||
execution_end_time: now - 115_000,
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-failed-1',
|
||||
status: 'failed',
|
||||
create_time: now - 30_000,
|
||||
execution_start_time: now - 30_000,
|
||||
execution_end_time: now - 28_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
|
||||
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()
|
||||
|
||||
// Expanded overlay should show job items
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
})
|
||||
|
||||
test('Overlay shows filter tabs (All, Completed)', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: 'All', exact: true })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: 'Completed', exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Overlay shows Failed tab when failed jobs exist', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: 'Failed', exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Completed filter shows only completed jobs', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Completed', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-completed-1"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-failed-1"]')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Toggling overlay again closes it', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
await toggle.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id]').first()
|
||||
).not.toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,20 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
|
||||
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
|
||||
return comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: nodeTitle })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
}
|
||||
|
||||
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
const nodePos = await nodeRef.getPosition()
|
||||
@@ -36,7 +47,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
() => window.app!.graph!._nodes.length
|
||||
)
|
||||
|
||||
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
|
||||
const deleteButton = comfyPage.page.getByTestId('delete-button')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
@@ -51,14 +62,12 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const infoButton = comfyPage.page.locator('[data-testid="info-button"]')
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="properties-panel"]')
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button visible with multi-select', async ({
|
||||
@@ -71,7 +80,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="convert-to-subgraph-button"]')
|
||||
comfyPage.page.getByTestId('convert-to-subgraph-button')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -88,7 +97,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
() => window.app!.graph!._nodes.length
|
||||
)
|
||||
|
||||
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
|
||||
const deleteButton = comfyPage.page.getByTestId('delete-button')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
@@ -98,4 +107,152 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
)
|
||||
expect(newCount).toBe(initialCount - 2)
|
||||
})
|
||||
|
||||
test('bypass button toggles bypass on single node', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(false)
|
||||
|
||||
const bypassButton = comfyPage.page.getByTestId('bypass-button')
|
||||
await expect(bypassButton).toBeVisible()
|
||||
await bypassButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, 'KSampler')).toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
|
||||
await bypassButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, 'KSampler')).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button converts node to subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const convertButton = comfyPage.page.getByTestId(
|
||||
'convert-to-subgraph-button'
|
||||
)
|
||||
await expect(convertButton).toBeVisible()
|
||||
await convertButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// KSampler should be gone, replaced by a subgraph node
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))
|
||||
.toHaveLength(0)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph'))
|
||||
.toHaveLength(1)
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button converts multiple nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const convertButton = comfyPage.page.getByTestId(
|
||||
'convert-to-subgraph-button'
|
||||
)
|
||||
await expect(convertButton).toBeVisible()
|
||||
await convertButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph'))
|
||||
.toHaveLength(1)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('frame nodes button creates group from multiple selected nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialGroupCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.groups.length
|
||||
)
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const frameButton = comfyPage.page.getByRole('button', {
|
||||
name: /Frame Nodes/i
|
||||
})
|
||||
await expect(frameButton).toBeVisible()
|
||||
await frameButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.graph.groups.length)
|
||||
)
|
||||
.toBe(initialGroupCount + 1)
|
||||
})
|
||||
|
||||
test('frame nodes button is not visible for single selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const frameButton = comfyPage.page.getByRole('button', {
|
||||
name: /Frame Nodes/i
|
||||
})
|
||||
await expect(frameButton).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('execute button visible when output node selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select the SaveImage node by panning to it
|
||||
const saveImageRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('Save Image')
|
||||
)[0]
|
||||
await selectNodeWithPan(comfyPage, saveImageRef)
|
||||
|
||||
const executeButton = comfyPage.page.getByRole('button', {
|
||||
name: /Execute to selected output nodes/i
|
||||
})
|
||||
await expect(executeButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('execute button not visible when non-output node selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const executeButton = comfyPage.page.getByRole('button', {
|
||||
name: /Execute to selected output nodes/i
|
||||
})
|
||||
await expect(executeButton).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
77
browser_tests/tests/sidebar/workflowSearch.spec.ts
Normal file
77
browser_tests/tests/sidebar/workflowSearch.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
/** Locate a workflow label in whatever panel is visible (browse or search). */
|
||||
function findWorkflow(page: Page, name: string) {
|
||||
return page
|
||||
.getByTestId('workflows-sidebar')
|
||||
.locator('.node-label', { hasText: name })
|
||||
}
|
||||
|
||||
test.describe('Workflow sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({
|
||||
'alpha-workflow.json': 'default.json',
|
||||
'beta-workflow.json': 'default.json'
|
||||
})
|
||||
})
|
||||
|
||||
test('Search input is visible in workflows tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search filters saved workflows by name', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('alpha')
|
||||
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'beta-workflow')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Clearing search restores all workflows', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('alpha')
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'beta-workflow')
|
||||
).not.toBeVisible()
|
||||
|
||||
await searchInput.fill('')
|
||||
|
||||
await expect(tab.getPersistedItem('alpha-workflow')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getPersistedItem('beta-workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty results', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('nonexistent_xyz')
|
||||
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'alpha-workflow')
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'beta-workflow')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -73,6 +73,7 @@
|
||||
"@primevue/themes": "catalog:",
|
||||
"@sentry/vue": "catalog:",
|
||||
"@sparkjsdev/spark": "catalog:",
|
||||
"@tanstack/vue-virtual": "catalog:",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-link": "catalog:",
|
||||
"@tiptap/extension-table": "catalog:",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver-ai}]");
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
|
||||
|
Before Width: | Height: | Size: 534 B After Width: | Height: | Size: 534 B |
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -102,6 +102,9 @@ catalogs:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: ^3.13.12
|
||||
version: 3.13.12
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.9.1
|
||||
version: 6.9.1
|
||||
@@ -458,6 +461,9 @@ importers:
|
||||
'@sparkjsdev/spark':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.10
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: 'catalog:'
|
||||
version: 3.13.12(vue@3.5.13(typescript@5.9.3))
|
||||
'@tiptap/core':
|
||||
specifier: 'catalog:'
|
||||
version: 2.27.2(@tiptap/pm@2.27.2)
|
||||
|
||||
@@ -30,6 +30,7 @@ catalog:
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@tanstack/vue-virtual': ^3.13.12
|
||||
'@storybook/addon-docs': ^10.2.10
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
|
||||
@@ -37,13 +37,13 @@
|
||||
</TreeRoot>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuPortal v-if="showContextMenu">
|
||||
<ContextMenuPortal v-if="showContextMenu && contextMenuNode?.data">
|
||||
<ContextMenuContent
|
||||
class="z-9999 min-w-32 overflow-hidden rounded-md border border-border-default bg-comfy-menu-bg p-1 shadow-md"
|
||||
>
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
|
||||
@select="handleAddToFavorites"
|
||||
@select="handleToggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
@@ -59,6 +59,14 @@
|
||||
: $t('sideToolbar.nodeLibraryTab.sections.favoriteNode')
|
||||
}}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="isCurrentNodeUserBlueprint"
|
||||
class="text-destructive flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
|
||||
@select="handleDeleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
{{ $t('g.delete') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</ContextMenuRoot>
|
||||
@@ -79,6 +87,7 @@ import { computed, provide, ref } from 'vue'
|
||||
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
@@ -98,7 +107,6 @@ const emit = defineEmits<{
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
|
||||
event: MouseEvent
|
||||
]
|
||||
addToFavorites: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
|
||||
@@ -107,6 +115,7 @@ const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
|
||||
provide(InjectKeyContextMenuNode, contextMenuNode)
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
|
||||
const isCurrentNodeBookmarked = computed(() => {
|
||||
const node = contextMenuNode.value
|
||||
@@ -114,9 +123,21 @@ const isCurrentNodeBookmarked = computed(() => {
|
||||
return nodeBookmarkStore.isBookmarked(node.data)
|
||||
})
|
||||
|
||||
function handleAddToFavorites() {
|
||||
if (contextMenuNode.value) {
|
||||
emit('addToFavorites', contextMenuNode.value)
|
||||
const isCurrentNodeUserBlueprint = computed(() =>
|
||||
subgraphStore.isUserBlueprint(contextMenuNode.value?.data?.name)
|
||||
)
|
||||
|
||||
function handleToggleBookmark() {
|
||||
const node = contextMenuNode.value
|
||||
if (node?.data) {
|
||||
nodeBookmarkStore.toggleBookmark(node.data)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteBlueprint() {
|
||||
const name = contextMenuNode.value?.data?.name
|
||||
if (name) {
|
||||
void subgraphStore.deleteBlueprint(name)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@ import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
messages: { en: { g: { delete: 'Delete' } } }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -29,6 +29,17 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockDeleteBlueprint = vi.fn()
|
||||
const mockIsUserBlueprint = vi.fn().mockReturnValue(false)
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: () => ({
|
||||
isUserBlueprint: mockIsUserBlueprint,
|
||||
deleteBlueprint: mockDeleteBlueprint,
|
||||
typePrefix: 'SubgraphBlueprint.'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
@@ -175,8 +186,12 @@ describe('TreeExplorerV2Node', () => {
|
||||
expect(contextMenuNode.value).toEqual(nodeItem.value)
|
||||
})
|
||||
|
||||
it('does not set contextMenuNode for folder items', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
it('clears contextMenuNode when right-clicking a folder', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>({
|
||||
key: 'stale',
|
||||
type: 'node',
|
||||
label: 'Stale'
|
||||
} as RenderedTreeExplorerNode)
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
{ item: createMockItem('folder') },
|
||||
@@ -194,6 +209,59 @@ describe('TreeExplorerV2Node', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('blueprint actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows delete button for user blueprints', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'SubgraphBlueprint.test' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides delete button for non-blueprint nodes', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(false)
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'KSampler' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('always shows bookmark button', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'SubgraphBlueprint.test' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="icon.bookmark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('calls deleteBlueprint when delete button is clicked', async () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const nodeName = 'SubgraphBlueprint.test'
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: nodeName }
|
||||
})
|
||||
})
|
||||
|
||||
await wrapper.find('[aria-label="Delete"]').trigger('click')
|
||||
|
||||
expect(mockDeleteBlueprint).toHaveBeenCalledWith(nodeName)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders node icon for node type', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
|
||||
@@ -25,25 +25,30 @@
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'hover:text-foreground flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
|
||||
'opacity-0 group-hover/tree-node:opacity-100'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
v-if="isUserBlueprint"
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-destructive')"
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] text-xs" />
|
||||
</button>
|
||||
<button
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder -->
|
||||
@@ -53,6 +58,7 @@
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@contextmenu="clearContextMenuNode"
|
||||
>
|
||||
<i
|
||||
v-if="item.hasChildren"
|
||||
@@ -96,6 +102,7 @@ import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -107,6 +114,9 @@ defineOptions({
|
||||
const ROW_CLASS =
|
||||
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
|
||||
|
||||
const ACTION_BTN_CLASS =
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
}>()
|
||||
@@ -120,6 +130,7 @@ const emit = defineEmits<{
|
||||
|
||||
const contextMenuNode = inject(InjectKeyContextMenuNode)
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
|
||||
const nodeDef = computed(() => item.value.data)
|
||||
|
||||
@@ -128,12 +139,22 @@ const isBookmarked = computed(() => {
|
||||
return nodeBookmarkStore.isBookmarked(nodeDef.value)
|
||||
})
|
||||
|
||||
const isUserBlueprint = computed(() =>
|
||||
subgraphStore.isUserBlueprint(nodeDef.value?.name)
|
||||
)
|
||||
|
||||
function toggleBookmark() {
|
||||
if (nodeDef.value) {
|
||||
nodeBookmarkStore.toggleBookmark(nodeDef.value)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteBlueprint() {
|
||||
if (nodeDef.value) {
|
||||
void subgraphStore.deleteBlueprint(nodeDef.value.name)
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
showPreview,
|
||||
@@ -166,6 +187,12 @@ function handleContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearContextMenuNode() {
|
||||
if (contextMenuNode) {
|
||||
contextMenuNode.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter(e: MouseEvent) {
|
||||
if (item.value.type !== 'node') return
|
||||
baseHandleMouseEnter(e)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="error-dialog"
|
||||
class="comfy-error-report flex flex-col gap-4"
|
||||
>
|
||||
<div class="comfy-error-report flex flex-col gap-4">
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
@@ -17,17 +14,11 @@
|
||||
</template>
|
||||
|
||||
<div class="flex justify-center gap-2">
|
||||
<Button
|
||||
v-show="!reportOpen"
|
||||
data-testid="error-dialog-show-report"
|
||||
variant="textonly"
|
||||
@click="showReport"
|
||||
>
|
||||
<Button v-show="!reportOpen" variant="textonly" @click="showReport">
|
||||
{{ $t('g.showReport') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-show="!reportOpen"
|
||||
data-testid="error-dialog-contact-support"
|
||||
variant="textonly"
|
||||
@click="showContactSupport"
|
||||
>
|
||||
@@ -49,11 +40,7 @@
|
||||
:repo-owner="repoOwner"
|
||||
:repo-name="repoName"
|
||||
/>
|
||||
<Button
|
||||
v-if="reportOpen"
|
||||
data-testid="error-dialog-copy-report"
|
||||
@click="copyReportToClipboard"
|
||||
>
|
||||
<Button v-if="reportOpen" @click="copyReportToClipboard">
|
||||
<i class="pi pi-copy" />
|
||||
{{ $t('g.copyToClipboard') }}
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<Button
|
||||
data-testid="error-dialog-find-issues"
|
||||
variant="secondary"
|
||||
@click="openGitHubIssues"
|
||||
>
|
||||
<Button variant="secondary" @click="openGitHubIssues">
|
||||
<i class="pi pi-github" />
|
||||
{{ $t('g.findIssues') }}
|
||||
</Button>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
|
||||
/>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="min-h-0 flex-1">
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItemEvent"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable vue/one-component-per-file -- test stubs */
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- stubs lack ARIA roles; data attributes for props */
|
||||
/* eslint-disable testing-library/prefer-user-event -- fireEvent needed: fake timers require fireEvent for mouseEnter/mouseLeave */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
@@ -6,21 +5,27 @@ import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import './testUtils/mockTanstackVirtualizer'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import JobAssetsList from './JobAssetsList.vue'
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template:
|
||||
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
|
||||
})
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
jobDetailsPopoverStub: {
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template:
|
||||
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
|
||||
default: hoisted.jobDetailsPopoverStub
|
||||
}))
|
||||
|
||||
const AssetsListItemStub = defineComponent({
|
||||
name: 'AssetsListItem',
|
||||
@@ -65,71 +70,81 @@ vi.mock('vue-i18n', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const createResultItem = (
|
||||
type TestPreviewOutput = {
|
||||
url: string
|
||||
isImage: boolean
|
||||
isVideo: boolean
|
||||
}
|
||||
|
||||
type TestTaskRef = {
|
||||
workflowId?: string
|
||||
previewOutput?: TestPreviewOutput
|
||||
}
|
||||
|
||||
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
|
||||
taskRef?: TestTaskRef
|
||||
}
|
||||
|
||||
type TestJobGroup = Omit<JobGroup, 'items'> & {
|
||||
items: TestJobListItem[]
|
||||
}
|
||||
|
||||
const createPreviewOutput = (
|
||||
filename: string,
|
||||
mediaType: string = 'images'
|
||||
): ResultItemImpl => {
|
||||
const item = new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType
|
||||
})
|
||||
Object.defineProperty(item, 'url', {
|
||||
get: () => `/api/view/${filename}`
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
|
||||
const job: ApiJobListItem = {
|
||||
id: `task-${Math.random().toString(36).slice(2)}`,
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
preview_output: null,
|
||||
outputs_count: preview ? 1 : 0,
|
||||
workflow_id: 'workflow-1',
|
||||
priority: 0
|
||||
): TestPreviewOutput => {
|
||||
const url = `/api/view/${filename}`
|
||||
return {
|
||||
url,
|
||||
isImage: mediaType === 'images',
|
||||
isVideo: mediaType === 'video'
|
||||
}
|
||||
const flatOutputs = preview ? [preview] : []
|
||||
return new TaskItemImpl(job, {}, flatOutputs)
|
||||
}
|
||||
|
||||
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
const createTaskRef = (preview?: TestPreviewOutput): TestTaskRef => ({
|
||||
workflowId: 'workflow-1',
|
||||
...(preview && { previewOutput: preview })
|
||||
})
|
||||
|
||||
const buildJob = (
|
||||
overrides: Partial<TestJobListItem> = {}
|
||||
): TestJobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png')),
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
|
||||
...overrides
|
||||
})
|
||||
|
||||
function renderJobAssetsList(
|
||||
jobs: JobListItem[],
|
||||
callbacks: {
|
||||
onViewItem?: (item: JobListItem) => void
|
||||
} = {}
|
||||
) {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items: jobs
|
||||
}
|
||||
]
|
||||
|
||||
function renderJobAssetsList({
|
||||
jobs = [],
|
||||
displayedJobGroups,
|
||||
attrs,
|
||||
onViewItem
|
||||
}: {
|
||||
jobs?: TestJobListItem[]
|
||||
displayedJobGroups?: TestJobGroup[]
|
||||
attrs?: Record<string, string>
|
||||
onViewItem?: (item: JobListItem) => void
|
||||
} = {}) {
|
||||
const user = userEvent.setup()
|
||||
|
||||
const result = render(JobAssetsList, {
|
||||
props: {
|
||||
displayedJobGroups,
|
||||
...(callbacks.onViewItem && { onViewItem: callbacks.onViewItem })
|
||||
displayedJobGroups: (displayedJobGroups ?? [
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items: jobs
|
||||
}
|
||||
]) as JobGroup[],
|
||||
...(onViewItem && { onViewItem })
|
||||
},
|
||||
attrs,
|
||||
global: {
|
||||
stubs: {
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub,
|
||||
AssetsListItem: AssetsListItemStub
|
||||
}
|
||||
}
|
||||
@@ -168,10 +183,57 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('JobAssetsList', () => {
|
||||
it('renders grouped headers alongside job rows', () => {
|
||||
const displayedJobGroups: TestJobGroup[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob({ id: 'job-1' })]
|
||||
},
|
||||
{
|
||||
key: 'yesterday',
|
||||
label: 'Yesterday',
|
||||
items: [buildJob({ id: 'job-2', title: 'Job 2' })]
|
||||
}
|
||||
]
|
||||
|
||||
const { container } = renderJobAssetsList({ displayedJobGroups })
|
||||
|
||||
expect(screen.getByText('Today')).toBeTruthy()
|
||||
expect(screen.getByText('Yesterday')).toBeTruthy()
|
||||
expect(container.querySelector('[data-job-id="job-1"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-job-id="job-2"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('forwards parent attrs to the scroll container', () => {
|
||||
renderJobAssetsList({
|
||||
attrs: {
|
||||
class: 'min-h-0 flex-1'
|
||||
},
|
||||
displayedJobGroups: [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob({ id: 'job-1' })]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('job-assets-list').className.split(' ')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'min-h-0',
|
||||
'flex-1',
|
||||
'h-full',
|
||||
'overflow-y-auto',
|
||||
'pb-4'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('emits viewItem on preview-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const onViewItem = vi.fn()
|
||||
const { user } = renderJobAssetsList([job], { onViewItem })
|
||||
const { user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
await user.click(screen.getByTestId('preview-trigger'))
|
||||
|
||||
@@ -181,7 +243,7 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on double-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const stubRoot = container.querySelector('.assets-list-item-stub')!
|
||||
await user.dblClick(stubRoot)
|
||||
@@ -192,10 +254,10 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const stubRoot = container.querySelector('.assets-list-item-stub')!
|
||||
expect(stubRoot.getAttribute('data-preview-url')).toBe(
|
||||
@@ -211,10 +273,10 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const icon = container.querySelector('.assets-list-item-stub i')!
|
||||
await user.click(icon)
|
||||
@@ -225,10 +287,10 @@ describe('JobAssetsList', () => {
|
||||
it('does not emit viewItem on double-click for non-completed jobs', async () => {
|
||||
const job = buildJob({
|
||||
state: 'running',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png'))
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const stubRoot = container.querySelector('.assets-list-item-stub')!
|
||||
await user.dblClick(stubRoot)
|
||||
@@ -242,7 +304,7 @@ describe('JobAssetsList', () => {
|
||||
taskRef: createTaskRef()
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container } = renderJobAssetsList([job], { onViewItem })
|
||||
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
@@ -256,7 +318,7 @@ describe('JobAssetsList', () => {
|
||||
it('shows and hides the job details popover with hover delays', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
@@ -286,7 +348,7 @@ describe('JobAssetsList', () => {
|
||||
it('keeps the job details popover open while hovering the popover', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
@@ -319,7 +381,7 @@ describe('JobAssetsList', () => {
|
||||
it('positions the popover to the right of rows near the left viewport edge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
@@ -344,7 +406,7 @@ describe('JobAssetsList', () => {
|
||||
it('positions the popover to the left of rows near the right viewport edge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
@@ -370,7 +432,9 @@ describe('JobAssetsList', () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
const { container } = renderJobAssetsList([firstJob, secondJob])
|
||||
const { container } = renderJobAssetsList({
|
||||
jobs: [firstJob, secondJob]
|
||||
})
|
||||
|
||||
const firstRow = container.querySelector('[data-job-id="job-1"]')!
|
||||
const secondRow = container.querySelector('[data-job-id="job-2"]')!
|
||||
@@ -398,7 +462,9 @@ describe('JobAssetsList', () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
const { container } = renderJobAssetsList([firstJob, secondJob])
|
||||
const { container } = renderJobAssetsList({
|
||||
jobs: [firstJob, secondJob]
|
||||
})
|
||||
|
||||
const firstRow = container.querySelector('[data-job-id="job-1"]')!
|
||||
const secondRow = container.querySelector('[data-job-id="job-2"]')!
|
||||
@@ -429,7 +495,7 @@ describe('JobAssetsList', () => {
|
||||
it('does not show details if the hovered row disappears before the show delay ends', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container, rerender } = renderJobAssetsList([job])
|
||||
const { container, rerender } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
|
||||
@@ -1,79 +1,92 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-3 pb-4">
|
||||
<div
|
||||
v-for="group in displayedJobGroups"
|
||||
:key="group.key"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="text-xs leading-none text-text-secondary">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<div
|
||||
v-for="job in group.items"
|
||||
:key="job.id"
|
||||
:data-job-id="job.id"
|
||||
@mouseenter="onJobEnter(job, $event)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
>
|
||||
<AssetsListItem
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
job.state === 'running' && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:preview-url="getJobPreviewUrl(job)"
|
||||
:is-video-preview="isVideoPreviewJob(job)"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@contextmenu.prevent.stop="$emit('menu', job, $event)"
|
||||
@dblclick.stop="emitViewItem(job)"
|
||||
@preview-click="emitViewItem(job)"
|
||||
@click.stop
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
v-bind="$attrs"
|
||||
data-testid="job-assets-list"
|
||||
class="h-full overflow-y-auto pb-4"
|
||||
@scroll="onListScroll"
|
||||
>
|
||||
<div :style="virtualWrapperStyle">
|
||||
<template v-for="{ row, virtualItem } in virtualRows" :key="row.key">
|
||||
<div
|
||||
v-if="row.type === 'header'"
|
||||
class="box-border px-3 pb-2 text-xs leading-none text-text-secondary"
|
||||
:style="getVirtualRowStyle(virtualItem)"
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emitCancelItem(job)"
|
||||
{{ row.label }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === 'job'"
|
||||
class="box-border px-3"
|
||||
:style="getVirtualRowStyle(virtualItem)"
|
||||
>
|
||||
<div
|
||||
:data-job-id="row.job.id"
|
||||
class="h-12"
|
||||
@mouseenter="onJobEnter(row.job, $event)"
|
||||
@mouseleave="onJobLeave(row.job.id)"
|
||||
>
|
||||
<AssetsListItem
|
||||
:class="
|
||||
cn(
|
||||
'size-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
row.job.state === 'running' && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:preview-url="getJobPreviewUrl(row.job)"
|
||||
:is-video-preview="isVideoPreviewJob(row.job)"
|
||||
:preview-alt="row.job.title"
|
||||
:icon-name="row.job.iconName ?? iconForJobState(row.job.state)"
|
||||
:icon-class="getJobIconClass(row.job)"
|
||||
:primary-text="row.job.title"
|
||||
:secondary-text="row.job.meta"
|
||||
:progress-total-percent="row.job.progressTotalPercent"
|
||||
:progress-current-percent="row.job.progressCurrentPercent"
|
||||
@contextmenu.prevent.stop="$emit('menu', row.job, $event)"
|
||||
@dblclick.stop="emitViewItem(row.job)"
|
||||
@preview-click="emitViewItem(row.job)"
|
||||
@click.stop
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emitDeleteItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="$emit('menu', job, $event)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
<template v-if="hoveredJobId === row.job.id" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(row.job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emitCancelItem(row.job)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(row.job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emitDeleteItem(row.job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="row.job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(row.job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="$emit('menu', row.job, $event)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,8 +110,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { VirtualItem } from '@tanstack/vue-virtual'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
@@ -110,6 +126,17 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
import { buildVirtualJobRows } from './buildVirtualJobRows'
|
||||
import type { VirtualJobRow } from './buildVirtualJobRows'
|
||||
|
||||
const HEADER_ROW_HEIGHT = 20
|
||||
const GROUP_ROW_GAP = 16
|
||||
const JOB_ROW_HEIGHT = 48
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -120,9 +147,43 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const activeRowElement = ref<HTMLElement | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
|
||||
const virtualizer = useVirtualizer({
|
||||
get count(): number {
|
||||
return flatRows.value.length
|
||||
},
|
||||
getItemKey(index: number) {
|
||||
return flatRows.value[index]?.key ?? index
|
||||
},
|
||||
estimateSize(index: number) {
|
||||
const row = flatRows.value[index]
|
||||
return row ? getRowHeight(row, index, flatRows.value) : JOB_ROW_HEIGHT
|
||||
},
|
||||
getScrollElement() {
|
||||
return scrollContainer.value
|
||||
},
|
||||
overscan: 12
|
||||
})
|
||||
const virtualRows = computed(() => {
|
||||
const rows = flatRows.value
|
||||
return virtualizer.value
|
||||
.getVirtualItems()
|
||||
.flatMap((virtualItem: VirtualItem) => {
|
||||
const row = rows[virtualItem.index]
|
||||
return row ? [{ row, virtualItem }] : []
|
||||
})
|
||||
})
|
||||
const virtualWrapperStyle = computed<CSSProperties>(() => ({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
...(flatRows.value.length > 0 && {
|
||||
height: `${virtualizer.value.getTotalSize()}px`
|
||||
})
|
||||
}))
|
||||
const {
|
||||
activeDetails,
|
||||
clearHoverTimers,
|
||||
@@ -135,6 +196,37 @@ const {
|
||||
onReset: clearPopoverAnchor
|
||||
})
|
||||
|
||||
function getVirtualRowStyle(virtualItem: VirtualItem): CSSProperties {
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
overflowAnchor: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
function getRowHeight(
|
||||
row: VirtualJobRow,
|
||||
index: number,
|
||||
rows: VirtualJobRow[]
|
||||
): number {
|
||||
if (row.type === 'header') {
|
||||
return HEADER_ROW_HEIGHT
|
||||
}
|
||||
|
||||
return (
|
||||
JOB_ROW_HEIGHT + (rows[index + 1]?.type === 'header' ? GROUP_ROW_GAP : 0)
|
||||
)
|
||||
}
|
||||
|
||||
function onListScroll() {
|
||||
hoveredJobId.value = null
|
||||
resetActiveDetails()
|
||||
}
|
||||
|
||||
function clearPopoverAnchor() {
|
||||
activeRowElement.value = null
|
||||
popoverPosition.value = null
|
||||
|
||||
82
src/components/queue/job/buildVirtualJobRows.test.ts
Normal file
82
src/components/queue/job/buildVirtualJobRows.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
import { buildVirtualJobRows } from './buildVirtualJobRows'
|
||||
|
||||
function buildJob(id: string): JobListItem {
|
||||
return {
|
||||
id,
|
||||
title: `Job ${id}`,
|
||||
meta: 'meta',
|
||||
state: 'completed'
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildVirtualJobRows', () => {
|
||||
it('flattens grouped jobs into headers and rows in display order', () => {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob('job-1'), buildJob('job-2')]
|
||||
},
|
||||
{
|
||||
key: 'yesterday',
|
||||
label: 'Yesterday',
|
||||
items: [buildJob('job-3')]
|
||||
}
|
||||
]
|
||||
|
||||
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
|
||||
{
|
||||
key: 'header-today',
|
||||
type: 'header',
|
||||
label: 'Today'
|
||||
},
|
||||
{
|
||||
key: 'job-job-1',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[0].items[0]
|
||||
},
|
||||
{
|
||||
key: 'job-job-2',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[0].items[1]
|
||||
},
|
||||
{
|
||||
key: 'header-yesterday',
|
||||
type: 'header',
|
||||
label: 'Yesterday'
|
||||
},
|
||||
{
|
||||
key: 'job-job-3',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[1].items[0]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a single group flattened without extra row metadata', () => {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob('job-1')]
|
||||
}
|
||||
]
|
||||
|
||||
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
|
||||
{
|
||||
key: 'header-today',
|
||||
type: 'header',
|
||||
label: 'Today'
|
||||
},
|
||||
{
|
||||
key: 'job-job-1',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[0].items[0]
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
37
src/components/queue/job/buildVirtualJobRows.ts
Normal file
37
src/components/queue/job/buildVirtualJobRows.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
export type VirtualJobRow =
|
||||
| {
|
||||
key: string
|
||||
type: 'header'
|
||||
label: string
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
type: 'job'
|
||||
job: JobListItem
|
||||
}
|
||||
|
||||
export function buildVirtualJobRows(
|
||||
displayedJobGroups: JobGroup[]
|
||||
): VirtualJobRow[] {
|
||||
const rows: VirtualJobRow[] = []
|
||||
|
||||
displayedJobGroups.forEach((group) => {
|
||||
rows.push({
|
||||
key: `header-${group.key}`,
|
||||
type: 'header',
|
||||
label: group.label
|
||||
})
|
||||
|
||||
group.items.forEach((job) => {
|
||||
rows.push({
|
||||
key: `job-${job.id}`,
|
||||
type: 'job',
|
||||
job
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/vue-virtual', async () => {
|
||||
const { computed } = await import('vue')
|
||||
|
||||
return {
|
||||
useVirtualizer: (options: {
|
||||
count: number
|
||||
estimateSize: (index: number) => number
|
||||
getItemKey?: (index: number) => number | string
|
||||
}) =>
|
||||
computed(() => {
|
||||
let start = 0
|
||||
const items = Array.from({ length: options.count }, (_, index) => {
|
||||
const size = options.estimateSize(index)
|
||||
const item = {
|
||||
key: options.getItemKey?.(index) ?? index,
|
||||
index,
|
||||
start,
|
||||
size
|
||||
}
|
||||
|
||||
start += size
|
||||
return item
|
||||
})
|
||||
|
||||
return {
|
||||
getVirtualItems: () => items,
|
||||
getTotalSize: () => start
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,163 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
})
|
||||
|
||||
vi.mock('@/composables/queue/useJobList', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const jobHistoryItem = {
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
url: '/api/view/job-1.png'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
useJobList: () => ({
|
||||
selectedJobTab: ref('All'),
|
||||
selectedWorkflowFilter: ref('all'),
|
||||
selectedSortMode: ref('mostRecent'),
|
||||
searchQuery: ref(''),
|
||||
hasFailedJobs: ref(false),
|
||||
filteredTasks: ref([]),
|
||||
groupedJobItems: ref([
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items: [jobHistoryItem]
|
||||
}
|
||||
])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({
|
||||
jobMenuEntries: [],
|
||||
cancelJob: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useQueueClearHistoryDialog', () => ({
|
||||
useQueueClearHistoryDialog: () => ({
|
||||
showQueueClearHistoryDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useResultGallery', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useResultGallery: () => ({
|
||||
galleryActiveIndex: ref(-1),
|
||||
galleryItems: ref([]),
|
||||
onViewItem: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync: <T extends (...args: never[]) => unknown>(
|
||||
fn: T
|
||||
) => fn
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
showDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
clearInitializationByJobIds: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
runningTasks: [],
|
||||
pendingTasks: [],
|
||||
delete: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const SidebarTabTemplateStub = {
|
||||
name: 'SidebarTabTemplate',
|
||||
props: ['title'],
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
|
||||
}
|
||||
|
||||
function mountComponent() {
|
||||
return mount(JobHistorySidebarTab, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
SidebarTabTemplate: SidebarTabTemplateStub,
|
||||
JobFilterTabs: true,
|
||||
JobFilterActions: true,
|
||||
JobHistoryActionsMenu: true,
|
||||
JobContextMenu: true,
|
||||
ResultGallery: true,
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('JobHistorySidebarTab', () => {
|
||||
it('shows the job details popover for jobs in the history panel', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountComponent()
|
||||
const jobRow = wrapper.find('[data-job-id="job-1"]')
|
||||
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
const popover = wrapper.findComponent(JobDetailsPopoverStub)
|
||||
expect(popover.exists()).toBe(true)
|
||||
expect(popover.props()).toMatchObject({
|
||||
jobId: 'job-1',
|
||||
workflowId: 'workflow-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -46,22 +46,25 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@view-item="onViewItem"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<JobAssetsList
|
||||
class="min-h-0 flex-1"
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@view-item="onViewItem"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
:root="favoritesRoot"
|
||||
show-context-menu
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
<div v-else class="px-6 py-2 text-xs text-muted-background">
|
||||
{{ $t('sideToolbar.nodeLibraryTab.noBookmarkedNodes') }}
|
||||
@@ -31,7 +30,6 @@
|
||||
:root="section.root"
|
||||
show-context-menu
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,12 +69,4 @@ const hasFavorites = computed(
|
||||
const favoritesRoot = computed(() =>
|
||||
fillNodeInfo(nodeBookmarkStore.bookmarkedRoot)
|
||||
)
|
||||
|
||||
function handleAddToFavorites(
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
) {
|
||||
if (node.data) {
|
||||
nodeBookmarkStore.toggleBookmark(node.data)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
<!-- Asset unsupported group notice -->
|
||||
<div
|
||||
v-if="isCloud && !group.isAssetSupported"
|
||||
data-testid="missing-model-import-unsupported"
|
||||
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
|
||||
>
|
||||
<i
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
</p>
|
||||
|
||||
<Button
|
||||
data-testid="missing-model-copy-name"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 hover:bg-transparent"
|
||||
@@ -33,7 +32,6 @@
|
||||
|
||||
<Button
|
||||
v-if="!isCloud && model.representative.url && !isAssetSupported"
|
||||
data-testid="missing-model-copy-url"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@@ -64,7 +62,6 @@
|
||||
|
||||
<Button
|
||||
v-if="model.referencingNodes.length > 0"
|
||||
data-testid="missing-model-expand"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="
|
||||
@@ -109,7 +106,6 @@
|
||||
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
data-testid="missing-model-locate"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
@@ -152,7 +148,6 @@
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<Button
|
||||
data-testid="missing-model-download"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1"
|
||||
|
||||
@@ -187,6 +187,35 @@ describe('useSubgraphStore', () => {
|
||||
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
describe('isUserBlueprint', () => {
|
||||
it('should return true for user blueprints', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
expect(store.isUserBlueprint('SubgraphBlueprint.test')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for global blueprints', async () => {
|
||||
await mockFetch(
|
||||
{},
|
||||
{
|
||||
global_bp: {
|
||||
name: 'Global Blueprint',
|
||||
info: { node_pack: 'comfy_essentials' },
|
||||
data: JSON.stringify(mockGraph)
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(store.isUserBlueprint('SubgraphBlueprint.global_bp')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-blueprint node types', () => {
|
||||
expect(store.isUserBlueprint('KSampler')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(store.isUserBlueprint(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blueprint badge display', () => {
|
||||
it('should set isGlobal flag on global blueprints', async () => {
|
||||
await mockFetch(
|
||||
|
||||
@@ -432,6 +432,12 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
return nodeDef !== undefined && nodeDef.isGlobal === true
|
||||
}
|
||||
|
||||
function isUserBlueprint(nodeType?: string): boolean {
|
||||
if (!nodeType?.startsWith(typePrefix)) return false
|
||||
const name = nodeType.slice(typePrefix.length)
|
||||
return name in subgraphCache && !isGlobalBlueprint(name)
|
||||
}
|
||||
|
||||
return {
|
||||
deleteBlueprint,
|
||||
editBlueprint,
|
||||
@@ -439,6 +445,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
getBlueprint,
|
||||
isGlobalBlueprint,
|
||||
isSubgraphBlueprint,
|
||||
isUserBlueprint,
|
||||
publishSubgraph,
|
||||
subgraphBlueprints,
|
||||
typePrefix
|
||||
|
||||
@@ -73,7 +73,7 @@ const PROVIDER_COLORS: Record<string, string | [string, string]> = {
|
||||
'moonvalley-marey': '#DAD9C5',
|
||||
openai: '#B6B6B6',
|
||||
pixverse: ['#B465E6', '#E8632A'],
|
||||
'quiver-ai': '#B6B6B6',
|
||||
quiver: '#B6B6B6',
|
||||
recraft: '#B6B6B6',
|
||||
reve: '#B6B6B6',
|
||||
rodin: '#F7F7F7',
|
||||
|
||||
Reference in New Issue
Block a user