Compare commits

..

1 Commits

Author SHA1 Message Date
Glary-Bot
f11789378f test: comprehensive Vue right-click context menu e2e coverage
Adds a new e2e suite filling gaps left by the existing
contextMenu.spec.ts: Node Info, Color/Shape submenus, Delete,
Run Branch (output-node only / hidden for non-output), Open in
Mask Editor, Align Selected To submenu, Distribute Nodes
submenu, and widget-extra options (Favorite/Rename Widget) on
hovered widgets.

Refactors the existing contextMenu.spec.ts to share fixtures
via a new browser_tests/fixtures/utils/contextMenuTestHelpers.ts
so both files use one canonical set of openContextMenu /
clickExactMenuItem / openMultiNodeContextMenu / getNodeRef /
getNodeWrapper helpers.
2026-05-13 19:58:06 +00:00
272 changed files with 2308 additions and 16223 deletions

View File

@@ -54,14 +54,10 @@ jobs:
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
# PRs run each test once to keep wall time bounded; main runs 3× so the
# baseline saved to perf-data has enough samples to median over noise.
- name: Run performance tests
id: perf
continue-on-error: true
env:
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
- name: Upload perf metrics
if: always()

View File

@@ -9,7 +9,6 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { getDefaultLocale } from '@frontend-locales/localeConfig'
import { createI18n } from 'vue-i18n'
function buildLocale<
@@ -168,7 +167,7 @@ const messages: Record<string, LocaleMessages> = {
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: getDefaultLocale(),
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -77,10 +77,7 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.creator.cta',
ctaHref: subscribeUrl('creator'),
featureIntroKey: 'pricing.plan.creator.featureIntro',
features: [
{ text: 'pricing.plan.creator.feature1' },
{ text: 'pricing.plan.creator.feature2' }
],
features: [{ text: 'pricing.plan.creator.feature1' }],
isPopular: true
},
{
@@ -93,10 +90,7 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.pro.cta',
ctaHref: subscribeUrl('pro'),
featureIntroKey: 'pricing.plan.pro.featureIntro',
features: [
{ text: 'pricing.plan.pro.feature1' },
{ text: 'pricing.plan.pro.feature2' }
]
features: [{ text: 'pricing.plan.pro.feature1' }]
},
{
id: 'enterprise',

View File

@@ -1119,10 +1119,6 @@ const translations = {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: '3 concurrent API jobs',
'zh-CN': '3 个并发 API 任务'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
@@ -1147,10 +1143,6 @@ const translations = {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: '5 concurrent API jobs',
'zh-CN': '5 个并发 API 任务'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {

View File

@@ -1,55 +0,0 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => ({
mockInit: vi.fn(),
mockCapture: vi.fn()
}))
vi.mock('posthog-js', () => ({
default: {
init: hoisted.mockInit,
capture: hoisted.mockCapture
}
}))
describe('initPostHog', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
const { initPostHog } = await import('./posthog')
initPostHog()
expect(hoisted.mockInit).toHaveBeenCalledOnce()
const initOptions = hoisted.mockInit.mock.calls[0][1]
expect(initOptions.person_profiles).toBe('identified_only')
expect(typeof initOptions.before_send).toBe('function')
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: { email: 'd@example.com', name: 'keep me' },
$set_once: { $email: 'e@example.com', plan: 'free' }
}
const result = initOptions.before_send(event)
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('$email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})

View File

@@ -1,7 +1,5 @@
import posthog from 'posthog-js'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
@@ -20,9 +18,7 @@ export function initPostHog() {
ui_host: POSTHOG_UI_HOST,
capture_pageview: false,
capture_pageleave: true,
person_profiles: 'identified_only',
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
before_send: createPostHogBeforeSend()
person_profiles: 'identified_only'
})
initialized = true
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -282,12 +282,10 @@ export class ComfyPage {
async setup({
clearStorage = true,
mockReleases = true,
url
mockReleases = true
}: {
clearStorage?: boolean
mockReleases?: boolean
url?: string
} = {}) {
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
@@ -319,7 +317,7 @@ export class ComfyPage {
}, this.id)
}
await this.goto({ url })
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForAppReady()
@@ -346,20 +344,14 @@ export class ComfyPage {
return assetPath(fileName)
}
async goto({ url }: { url?: string } = {}) {
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
async goto() {
await this.page.goto(this.url)
}
async nextFrame() {
await nextFrame(this.page)
}
async idleFrames(count: number) {
for (let i = 0; i < count; i++) {
await this.nextFrame()
}
}
async delay(ms: number) {
return sleep(ms)
}

View File

@@ -217,20 +217,13 @@ export class VueNodeHelpers {
}
}
/**
* Locator for the Enter Subgraph footer button.
*/
getSubgraphEnterButton(nodeId?: string): Locator {
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
}
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const editButton = this.getSubgraphEnterButton(nodeId)
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.

View File

@@ -39,32 +39,10 @@ class ComfyQueueButton {
await this.dropdownButton.click()
return new ComfyQueueButtonOptions(this.actionbar.page)
}
public async openOptions() {
const options = new ComfyQueueButtonOptions(this.actionbar.page)
if (!(await options.menu.isVisible())) {
await this.dropdownButton.click()
}
return options
}
}
class ComfyQueueButtonOptions {
public readonly menu: Locator
public readonly modeItems: Locator
constructor(public readonly page: Page) {
this.menu = page.getByRole('menu')
this.modeItems = this.menu.getByRole('menuitem')
}
public modeItem(name: string) {
return this.menu.getByRole('menuitem', { name, exact: true })
}
public async selectMode(name: string) {
await this.modeItem(name).click()
}
constructor(public readonly page: Page) {}
public async setMode(mode: AutoQueueMode) {
await this.page.evaluate((mode) => {

View File

@@ -95,7 +95,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
@@ -104,7 +103,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}
getTab(name: string) {

View File

@@ -215,12 +215,11 @@ export class AssetHelper {
return this.store.size
}
private handleListAssets(route: Route, url: URL) {
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
let filtered = this.getFilteredAssets(includeTags, excludeTags)
let filtered = this.getFilteredAssets(includeTags)
if (limit > 0) {
filtered = filtered.slice(offset, offset + limit)
}
@@ -297,29 +296,15 @@ export class AssetHelper {
this.paginationOptions = null
this.uploadResponse = null
}
private getFilteredAssets(
includeTags: string[],
excludeTags: string[]
): Asset[] {
private getFilteredAssets(tags: string[]): Asset[] {
const assets = [...this.store.values()]
if (tags.length === 0) return assets
return assets.filter(
(asset) =>
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
return assets.filter((asset) =>
tags.every((tag) => (asset.tags ?? []).includes(tag))
)
}
}
function parseAssetTagParam(value: string | null): string[] {
return (
value
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
export function createAssetHelper(
page: Page,
...operators: AssetOperator[]

View File

@@ -6,71 +6,6 @@ import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
function readFilePayload(filePath: string) {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
return { bufferArray, fileName, fileType }
}
async function dispatchFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const target = document.activeElement ?? document
target.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
}, payload)
}
async function interceptNextFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
document.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
},
{ capture: true, once: true }
)
}, payload)
}
type PasteFileOptions = {
mode?: 'keyboard' | 'direct'
}
export class ClipboardHelper {
constructor(
private readonly keyboard: KeyboardHelper,
@@ -85,20 +20,43 @@ export class ClipboardHelper {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(
filePath: string,
{ mode = 'keyboard' }: PasteFileOptions = {}
): Promise<void> {
const payload = readFilePayload(filePath)
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
if (mode === 'keyboard') {
await interceptNextFilePaste(this.page, payload)
await this.paste()
return
}
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
// Dispatch the app-level paste event with file clipboardData directly.
await dispatchFilePaste(this.page, payload)
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
}
}

View File

@@ -1,26 +1,19 @@
import { test as base, expect } from '@playwright/test'
import type { Page, Route, WebSocketRoute } from '@playwright/test'
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { LogsRawResponse } from '@/schemas/apiSchema'
const RAW_LOGS_URL = '**/internal/logs/raw**'
const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**'
export class LogsTerminalHelper {
constructor(private readonly page: Page) {}
async mockRawLogs(messages: string[]): Promise<() => number> {
let count = 0
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({
async mockRawLogs(messages: string[]) {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
})
return () => count
)
}
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
@@ -28,8 +21,7 @@ export class LogsTerminalHelper {
const pending = new Promise<void>((r) => {
resolve = r
})
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
await pending
await route.fulfill({
status: 200,
@@ -41,39 +33,15 @@ export class LogsTerminalHelper {
}
async mockRawLogsError() {
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, (route: Route) =>
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
}
async mockSubscribeLogs(): Promise<() => number> {
let count = 0
await this.page.unroute(SUBSCRIBE_LOGS_URL)
await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({ status: 200, body: '' })
})
return () => count
}
/**
* Force the frontend to reconnect by closing the proxied WebSocket. The
* api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket
* handler fires again, and on `open` with `isReconnect=true` it dispatches
* `'reconnected'`, which triggers the logs-terminal resync.
*
* Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by
* the time the count goes up, the new socket is open and resync has
* completed enough to assert against the terminal.
*/
async triggerReconnect(
ws: WebSocketRoute,
subscribeFetches: () => number
): Promise<void> {
const before = subscribeFetches()
await ws.close()
await expect.poll(subscribeFetches).toBeGreaterThan(before)
async mockSubscribeLogs() {
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
route.fulfill({ status: 200, body: '' })
)
}
static buildWsLogFrame(messages: string[]): string {

View File

@@ -8,7 +8,6 @@ export const TestIds = {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
@@ -76,15 +75,7 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
cloudNotification: 'cloud-notification-dialog'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'

View File

@@ -1,250 +0,0 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
ImportPublishedAssetsRequest,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { z } from 'zod'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import type { AssetInfo } from '@/schemas/apiSchema'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
export const sharedWorkflowImportScenario = {
shareId: 'shared-missing-media-e2e',
workflowId: 'shared-missing-media-workflow',
publishedAssetId: 'published-input-asset-1',
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'
export interface SharedWorkflowImportMocks {
resetAndStartRecording: () => void
getImportBody: () => ImportPublishedAssetsRequest | undefined
getRequestEvents: () => SharedWorkflowRequestEvent[]
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
}
const defaultInputFileName = '00000000000000000000000Aexample.png'
const sharedWorkflowAsset: AssetInfo = {
id: sharedWorkflowImportScenario.publishedAssetId,
name: sharedWorkflowImportScenario.inputFileName,
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const sharedWorkflowResponse: SharedWorkflowResponse = {
share_id: sharedWorkflowImportScenario.shareId,
workflow_id: sharedWorkflowImportScenario.workflowId,
name: 'Shared Missing Media Workflow',
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 10,
last_link_id: 0,
nodes: [
{
id: 10,
type: 'LoadImage',
pos: [50, 200],
size: [315, 314],
flags: {},
order: 0,
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: [sharedWorkflowImportScenario.inputFileName, 'image']
}
],
links: [],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
}
}
},
assets: [sharedWorkflowAsset]
}
export const sharedWorkflowImportFixture = base.extend<{
sharedWorkflowImportMocks: SharedWorkflowImportMocks
}>({
sharedWorkflowImportMocks: async ({ page }, use) => {
const mocks = await mockSharedWorkflowImportFlow(page)
await use(mocks)
}
})
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
const requestEvents: SharedWorkflowRequestEvent[] = []
function resetPublicInclusiveInputAssetResponseWaiter() {
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
}
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
if (isRecording) requestEvents.push(event)
}
await page.route(
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(sharedWorkflowResponse)
})
}
)
await page.route('**/api/assets/import', async (route) => {
recordRequestEvent('import')
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
importEndpointCalled = true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
// Excludes `/api/assets/import` so the specific route above
// remains isolated from the general asset listing mock.
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const url = new URL(route.request().url())
const includeTags = getTagParam(url, 'include_tags')
const isInputAssetRequest = includeTags.includes('input')
const includesPublicAssets =
url.searchParams.get('include_public') === 'true'
const isPublicInclusiveInputAssetRequest =
isInputAssetRequest && includesPublicAssets
const isAfterImportPublicInclusiveInputAssetRequest =
isPublicInclusiveInputAssetRequest && importEndpointCalled
if (isPublicInclusiveInputAssetRequest) {
recordRequestEvent(
importEndpointCalled
? 'input-assets-including-public-after-import'
: 'input-assets-including-public-before-import'
)
}
const allAssets = [
defaultInputAsset,
...(importEndpointCalled ? [importedInputAsset] : [])
]
const assets = includeTags.length
? allAssets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
if (isAfterImportPublicInclusiveInputAssetRequest) {
resolvePublicInclusiveInputAssetResponseAfterImport()
}
})
return {
resetAndStartRecording: () => {
isRecording = true
importEndpointCalled = false
importBody = undefined
requestEvents.length = 0
resetPublicInclusiveInputAssetResponseWaiter()
},
getImportBody: () => importBody,
getRequestEvents: () => [...requestEvents],
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
publicInclusiveInputAssetResponseAfterImport
}
}
function getTagParam(url: URL, key: string): string[] {
return (
url.searchParams
.get(key)
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}

View File

@@ -0,0 +1,90 @@
import type { Locator } from '@playwright/test'
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
/**
* Click a menu item by exact label and wait for the menu to close.
*/
export async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
await comfyPage.contextMenu.clickMenuItemExact(name)
await expect(comfyPage.contextMenu.primeVueMenu).toBeHidden()
}
/**
* Open the context menu for a single Vue node by title.
* Selects the node first (required for correct menu items).
*/
export async function openContextMenu(
comfyPage: ComfyPage,
nodeTitle: string
): Promise<Locator> {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await comfyPage.contextMenu.openForVueNode(fixture.header)
return comfyPage.contextMenu.primeVueMenu
}
/**
* Open the context menu for multiple selected Vue nodes.
*/
export async function openMultiNodeContextMenu(
comfyPage: ComfyPage,
titles: string[]
): Promise<Locator> {
if (titles.length === 0) {
throw new Error('openMultiNodeContextMenu requires at least one title')
}
// deselectAll via evaluate — clearSelection() clicks at a fixed position
// which can hit nodes or the toolbar overlay
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
await comfyPage.nextFrame()
for (const title of titles) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await fixture.header.click({ modifiers: ['ControlOrMeta'] })
}
await comfyPage.nextFrame()
const firstFixture = await comfyPage.vueNodes.getFixtureByTitle(titles[0])
const box = await firstFixture.header.boundingBox()
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
await comfyPage.page.mouse.click(
box.x + box.width / 2,
box.y + box.height / 2,
{ button: 'right' }
)
const menu = comfyPage.contextMenu.primeVueMenu
await menu.waitFor({ state: 'visible' })
return menu
}
/**
* Get the inner wrapper locator for a Vue node by title.
*/
export function getNodeWrapper(
comfyPage: ComfyPage,
nodeTitle: string
): Locator {
return comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.getByTestId(TestIds.node.innerWrapper)
}
/**
* Get the first NodeReference matching the given title.
*/
export async function getNodeRef(
comfyPage: ComfyPage,
nodeTitle: string
): Promise<NodeReference> {
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
const firstRef = refs[0]
if (!firstRef) {
throw new Error(`No node found with title "${nodeTitle}"`)
}
return firstRef
}

View File

@@ -1,28 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export async function openMoreOptionsMenu(
comfyPage: ComfyPage,
nodeTitle: string
) {
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
if (nodes.length === 0) {
throw new Error(`No "${nodeTitle}" nodes found`)
}
await nodes[0].centerOnNode()
await nodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
return menu
}

View File

@@ -133,29 +133,6 @@ test.describe('AssetHelper', () => {
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
})
test('GET /assets filters by exclude_tags', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withAsset(STABLE_INPUT_IMAGE),
withAsset({
...STABLE_INPUT_IMAGE,
id: 'missing-input',
tags: ['input', 'missing']
})
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
)
const data = body as { assets: Array<{ id: string }> }
expect(data.assets.map((asset) => asset.id)).toEqual([
STABLE_INPUT_IMAGE.id
])
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage,
assetApi

View File

@@ -147,68 +147,5 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
})
test('resyncs the terminal when the WebSocket reconnects', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
const initialLine = 'pre-reboot log line'
const postRebootLineA = 'post-reboot line A'
const postRebootLineB = 'post-reboot line B'
await logsTerminal.mockRawLogs([initialLine])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
initialLine
)
// Swap the raw-logs mock so the next fetch returns the post-reboot view.
await logsTerminal.mockRawLogs([postRebootLineA, postRebootLineB])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineA
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineB
)
// reset() before write means the pre-reboot line must be gone.
await expect(comfyPage.bottomPanel.logs.terminalRoot).not.toContainText(
initialLine
)
})
test('resumes WebSocket log streaming after the reconnect', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs(['initial'])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
'initial'
)
await logsTerminal.mockRawLogs(['after-reboot snapshot'])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
// The route handler fires again on the new connection; pull the latest
// WebSocketRoute and push a live frame to prove the 'logs' listener
// survived the reconnect.
const liveLine = 'live log emitted after the reconnect'
const newWs = await getWebSocket()
newWs.send(LogsTerminalHelper.buildWsLogFrame([liveLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
liveLine
)
})
})
})

View File

@@ -1,47 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
//
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
// must render translated strings, not literal i18n keys like
// 'sideToolbar.labels.assets'.
test.describe('i18n locale fallback', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.addInitScript(() => {
Object.defineProperty(navigator, 'language', {
value: 'de-DE',
configurable: true
})
Object.defineProperty(navigator, 'languages', {
value: ['de-DE', 'de'],
configurable: true
})
})
// Default sidebar size on small viewports hides labels; force normal so
// .side-bar-button-label is rendered for the assertion.
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.page.reload()
await comfyPage.waitForAppReady()
})
test('sidebar labels render translated strings, not raw i18n keys', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.setViewportSize({ width: 1920, height: 1080 })
const labelTexts = await page
.getByTestId('side-toolbar')
.locator('.side-bar-button-label')
.allTextContents()
expect(labelTexts.length).toBeGreaterThan(0)
for (const text of labelTexts) {
expect(text).not.toContain('sideToolbar.labels')
}
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,548 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const MULTI_BINDING_COMMAND = 'Comfy.Canvas.DeleteSelectedItems'
const SINGLE_BINDING_COMMAND = 'Comfy.SaveWorkflow'
const NO_BINDING_COMMAND = 'TestCommand.KeybindingPanelE2E.NoBinding'
async function searchKeybindings(page: Page, query: string) {
await getKeybindingSearchInput(page).fill(query)
}
async function clearSearch(page: Page) {
await getKeybindingSearchInput(page).clear()
}
function getKeybindingSearchInput(page: Page): Locator {
return page.getByPlaceholder('Search Keybindings...')
}
function getCommandRow(page: Page, commandId: string): Locator {
return page
.locator('.keybinding-panel tr')
.filter({ has: page.locator(`[title="${commandId}"]`) })
}
function getExpansionContent(page: Page, commandId: string): Locator {
// PrimeVue renders the expansion row as the next sibling <tr> of the
// expanded row. Scoping by sibling avoids matching unrelated expanded rows.
return getCommandRow(page, commandId)
.locator('xpath=following-sibling::tr[1]')
.getByTestId('keybinding-expansion-content')
}
async function openContextMenu(page: Page, commandId: string) {
const row = getCommandRow(page, commandId)
await row.locator(`[title="${commandId}"]`).click({ button: 'right' })
await expect(
page.getByRole('menuitem', { name: /Change keybinding/i })
).toBeVisible()
}
function getKeybindingInput(page: Page): Locator {
return getEditKeybindingDialog(page).locator('input[autofocus]')
}
function getEditKeybindingDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Modify keybinding/i })
}
function getRemoveAllKeybindingsDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Remove all keybindings/i })
}
function getResetAllKeybindingsDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Reset all keybindings/i })
}
async function pressComboOnInput(page: Page, combo: string) {
const input = getKeybindingInput(page)
await expect(input).toBeFocused()
await input.press(combo)
}
async function saveAndCloseKeybindingDialog(page: Page) {
const dialog = getEditKeybindingDialog(page)
await dialog.getByRole('button', { name: /Save/i }).click()
await expect(dialog).toBeHidden()
}
async function cancelAndCloseDialog(page: Page) {
const dialog = getEditKeybindingDialog(page)
await dialog.getByRole('button', { name: /Cancel/i }).click()
await expect(dialog).toBeHidden()
}
async function addKeybindingToRow(page: Page, row: Locator, combo: string) {
await row.getByRole('button', { name: /Add new keybinding/i }).click()
await pressComboOnInput(page, combo)
await saveAndCloseKeybindingDialog(page)
}
test.beforeEach(async ({ comfyPage }) => {
await registerNoBindingCommand(comfyPage)
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [])
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [])
})
async function registerNoBindingCommand(comfyPage: ComfyPage) {
await comfyPage.page.evaluate((commandId) => {
const app = window.app!
app.registerExtension({
name: 'TestExtension.KeybindingPanelE2E',
commands: [{ id: commandId, function: () => {} }]
})
}, NO_BINDING_COMMAND)
}
test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
test.describe('Row Expansion', () => {
test('Click on row with 2+ keybindings toggles expansion', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
await expect(expansionContent).toBeHidden()
})
test('Click on row with 1 keybinding does not expand', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, SINGLE_BINDING_COMMAND)
await expect(expansionContent).toBeHidden()
})
})
test.describe('Double-Click', () => {
test('Double-click row with 0 keybindings opens Add dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${NO_BINDING_COMMAND}"]`).dblclick()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Double-click row with 1 keybinding opens Edit dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).dblclick()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
})
test.describe('Context Menu', () => {
test('Right-click row shows context menu with correct items', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
const changeItem = page.getByRole('menuitem', {
name: /Change keybinding/i
})
const addItem = page.getByRole('menuitem', {
name: /Add new keybinding/i
})
const resetItem = page.getByRole('menuitem', {
name: /Reset to default/i
})
const removeItem = page.getByRole('menuitem', {
name: /Remove keybinding/i
})
await expect(changeItem).toBeVisible()
await expect(addItem).toBeVisible()
await expect(resetItem).toBeVisible()
await expect(removeItem).toBeVisible()
await page.keyboard.press('Escape')
})
test("Context menu 'Add new keybinding' opens add dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Add new keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test("Context menu 'Change keybinding' on single-binding command opens edit dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test("Context menu 'Change keybinding' on multi-binding command expands row", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeHidden()
await openContextMenu(page, MULTI_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
await expect(expansionContent).toBeVisible()
})
test("Context menu 'Remove keybinding' after adding second binding shows confirm dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F9')
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Remove keybinding/i }).click()
const confirmDialog = getRemoveAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Remove all/i }).click()
await expect(row.locator('td').nth(1)).toContainText('-')
})
test("Context menu 'Reset to default' resets modified command", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F10')
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Reset to default/i }).click()
await expect(row.getByRole('button', { name: /Reset/i })).toBeDisabled()
})
test('Context menu items disabled when no keybindings', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
await openContextMenu(page, NO_BINDING_COMMAND)
const changeItem = page.getByRole('menuitem', {
name: /Change keybinding/i
})
const removeItem = page.getByRole('menuitem', {
name: /Remove keybinding/i
})
await expect(changeItem).toHaveAttribute('data-disabled', '')
await expect(removeItem).toHaveAttribute('data-disabled', '')
await page.keyboard.press('Escape')
})
})
test.describe('Action Buttons', () => {
test('Edit button opens edit dialog for single-binding command', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
const editButton = row.getByRole('button', { name: /^Edit$/i })
await expect(editButton).toBeVisible()
await editButton.click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Add button opens add dialog', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await row.getByRole('button', { name: /Add new keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Reset button is disabled for unmodified commands', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
const resetButton = row.getByRole('button', { name: /Reset/i })
await expect(resetButton).toBeDisabled()
})
test('Reset button resets modified keybinding', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F11')
const resetButton = row.getByRole('button', { name: /Reset/i })
await expect(resetButton).toBeEnabled()
await resetButton.click()
await expect(resetButton).toBeDisabled()
})
test('Delete button is disabled for commands with 0 keybindings', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
const deleteButton = row.getByRole('button', { name: /Delete/i })
await expect(deleteButton).toBeDisabled()
})
test('Delete button removes single keybinding directly', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F12')
const deleteButton = row.getByRole('button', { name: /Delete/i })
await expect(deleteButton).toBeEnabled()
await deleteButton.click()
await expect(row.locator('td').nth(1)).toContainText('-')
})
test('Delete button on command with 2+ keybindings shows confirm dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
const deleteButton = row.getByRole('button', { name: /Delete/i })
await deleteButton.click()
const confirmDialog = getRemoveAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
await expect(confirmDialog).toBeHidden()
await expect(row.locator('td').nth(1)).not.toContainText('-')
})
})
test.describe('Expanded Row Actions', () => {
test('Edit button in expanded row opens edit dialog for that binding', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
const firstBindingRow = expansionContent
.getByTestId('keybinding-expansion-binding')
.first()
await firstBindingRow.getByRole('button', { name: /^Edit$/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Delete button in expanded row removes that binding and collapses', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
const bindingRows = expansionContent.getByTestId(
'keybinding-expansion-binding'
)
await expect
.poll(() => bindingRows.count(), {
message: 'Expected at least 2 bindings'
})
.toBeGreaterThanOrEqual(2)
const initialBindingCount = await bindingRows.count()
await bindingRows
.first()
.getByRole('button', { name: /Remove keybinding/i })
.click()
if (initialBindingCount === 2) {
// Expansion auto-collapses when bindings drop below 2
await expect(expansionContent).toBeHidden()
} else {
await expect(bindingRows).toHaveCount(initialBindingCount - 1)
}
})
})
test.describe('Reset All', () => {
test('Reset All button shows confirmation and resets on confirm', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F8')
await expect(row.getByRole('button', { name: /Reset/i })).toBeEnabled()
await clearSearch(page)
const resetAllButton = page
.locator('.keybinding-panel')
.getByRole('button', { name: /Reset All/i })
await resetAllButton.click()
const confirmDialog = getResetAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await expect(confirmDialog).toContainText(/Reset all keybindings/i)
await confirmDialog.getByRole('button', { name: /Reset All/i }).click()
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const rowAfterReset = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(
rowAfterReset.getByRole('button', { name: /Reset/i })
).toBeDisabled()
})
test('Reset All confirmation can be cancelled', async ({ comfyPage }) => {
const { page } = comfyPage
const resetAllButton = page
.locator('.keybinding-panel')
.getByRole('button', { name: /Reset All/i })
await resetAllButton.click()
const confirmDialog = getResetAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
await expect(confirmDialog).toBeHidden()
})
})
test.describe('Search Filter', () => {
test('Typing in search clears expanded rows', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
// Changing the filter triggers watch(filters, ...) which clears expansion
await searchKeybindings(page, MULTI_BINDING_COMMAND + ' ')
await expect(expansionContent).toBeHidden()
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -51,7 +51,7 @@ test.describe(
return menu
}
test('last menu item "Remove" is reachable via scroll', async ({
test('last menu item "Delete" is reachable via scroll', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
@@ -67,26 +67,26 @@ test.describe(
)
.toBe(true)
// "Remove" is the last item in the More Options menu.
// "Delete" is the last item in the More Options menu.
// It must become reachable by scrolling the bounded menu list.
const removeItem = menu.getByText('Remove', { exact: true })
const deleteItem = menu.getByText('Delete', { exact: true })
const didScroll = await rootList.evaluate((el) => {
const previousScrollTop = el.scrollTop
el.scrollTo({ top: el.scrollHeight })
return el.scrollTop > previousScrollTop
})
expect(didScroll).toBe(true)
await expect(removeItem).toBeVisible()
await expect(deleteItem).toBeVisible()
})
test('last menu item "Remove" is clickable and removes the node', async ({
test('last menu item "Delete" is clickable and removes the node', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const removeItem = menu.getByText('Remove', { exact: true })
await removeItem.scrollIntoViewIfNeeded()
await removeItem.click()
const deleteItem = menu.getByText('Delete', { exact: true })
await deleteItem.scrollIntoViewIfNeeded()
await deleteItem.click()
await comfyPage.nextFrame()
// The node should be removed from the graph

View File

@@ -1,65 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.describe(
'Node context menu shape submenu (FE-570)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
const popover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(popover).toBeVisible()
await expect(popover).toContainText('Box')
await expect(popover).toContainText('Card')
const popoverBox = await popover.boundingBox()
expect(popoverBox).not.toBeNull()
expect(popoverBox!.width).toBeGreaterThan(0)
expect(popoverBox!.height).toBeGreaterThan(0)
}
test('Shape popover opens when the menu fits in the viewport', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
.toBe('visible')
await menu.getByRole('menuitem', { name: 'Shape' }).click()
await expectShapePopoverVisible(comfyPage)
})
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()
await shapeItem.click()
await expectShapePopoverVisible(comfyPage)
})
}
)

View File

@@ -351,45 +351,6 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
})
})
test(
'subgraph transition (enter and exit)',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }, testInfo) => {
// Heaviest perf test: loads an 80-node subgraph and pays ~30s/repeat.
// The signal is dominated by N=80 mount cost, so a single sample per
// CI invocation is sufficient — early-return on subsequent repeats.
if (testInfo.repeatEachIndex > 0) return
// Load workflow with a subgraph containing 80 interior nodes.
// Entering the subgraph unmounts root nodes and mounts all 80 interior
// nodes synchronously — this is the bottleneck we're measuring.
await comfyPage.workflow.loadWorkflow('subgraphs/large-subgraph-80-nodes')
await comfyPage.idleFrames(30)
await comfyPage.vueNodes.enterSubgraph()
await comfyPage.vueNodes.waitForNodes(80)
await comfyPage.idleFrames(30)
// Exit back to root graph before measuring a fresh enter/exit cycle
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.idleFrames(10)
// Start measuring the enter transition
await comfyPage.perf.startMeasuring()
await comfyPage.vueNodes.enterSubgraph()
await comfyPage.vueNodes.waitForNodes(80)
await comfyPage.idleFrames(30)
const m = await comfyPage.perf.stopMeasuring('subgraph-transition-enter')
recordMeasurement(m)
console.log(
`Subgraph enter (80 nodes): ${m.taskDurationMs.toFixed(0)}ms task, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
}
)
test('workflow execution', async ({ comfyPage }) => {
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
await comfyPage.workflow.loadWorkflow('execution/partial_execution')

View File

@@ -1,42 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('PreviewAny')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
// Simulate a previous execution: backend returned text and the frontend
// populated the preview widget values. The next prompt submission must
// NOT echo those values back as inputs (which would change the cache
// signature and trigger a redundant re-execution).
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
for (const widget of node.widgets ?? []) {
if (widget.name?.startsWith('preview_')) {
widget.value = 'rendered preview content from previous execution'
}
}
})
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: true
})
const previewEntry = Object.values(apiWorkflow).find(
(n) => n.class_type === 'PreviewAny'
)
expect(previewEntry).toBeDefined()
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
})

View File

@@ -1,63 +0,0 @@
import { expect } from '@playwright/test'
import type { PromptResponse } from '@/schemas/apiSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
const runOnChangeLabel = queueModeLabels[1]
test.describe('Queue button modes', { tag: '@ui' }, () => {
test('Run button is visible in topbar', async ({ comfyPage }) => {
await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
})
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
})
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
const options = await comfyPage.actionbar.queueButton.openOptions()
await expect(options.menu).toBeVisible()
})
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
const options = await comfyPage.actionbar.queueButton.openOptions()
await expect(options.menu).toBeVisible()
await expect(options.modeItems).toHaveText(queueModeLabels)
})
test('Selecting a non-default mode updates the Run button label', async ({
comfyPage
}) => {
const queueButton = comfyPage.actionbar.queueButton
const options = await queueButton.openOptions()
await expect(options.menu).toBeVisible()
await options.selectMode(runOnChangeLabel)
await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
})
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route('**/api/prompt', async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
})
await comfyPage.actionbar.queueButton.primaryButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
})

View File

@@ -16,7 +16,7 @@ test.describe(
await comfyPage.runButton.click()
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('Save WEBM')
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
// Wait for SaveImage to render an img inside .image-preview
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -54,44 +54,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
.toBe(initialCount - 1)
})
test('info button opens the right-side info tab in new menu mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
test('info button opens properties panel', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click()
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(panel).toContainText('KSampler')
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('info button is hidden when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(
comfyPage.selectionToolbox.getByTestId('info-button')
).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('convert-to-subgraph button visible with multi-select', async ({

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -19,19 +18,70 @@ test.describe(
await comfyPage.nextFrame()
})
const openMoreOptions = (comfyPage: ComfyPage) =>
openMoreOptionsMenu(comfyPage, 'KSampler')
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) {
throw new Error(
'Viewport size is null - page may not be properly initialized'
)
}
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
name: 'Node Info'
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
})
await expect(nodeInfoButton).toBeHidden()
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
@@ -40,14 +90,11 @@ test.describe(
)[0]
await openMoreOptions(comfyPage)
// Shape now opens via body-appended popover (FE-570); a hover no
// longer reveals the submenu — match the Color flow and click.
await comfyPage.page.getByText('Shape', { exact: true }).click()
const shapePopover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
await shapePopover.getByText('Box', { exact: true }).click()
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible()
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)

View File

@@ -1,145 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
sharedWorkflowImportFixture,
sharedWorkflowImportScenario
} from '@e2e/fixtures/sharedWorkflowImportFixture'
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { WorkspaceStore } from '@e2e/types/globals'
const IMPORT_ORDER_TIMEOUT_MS = 5_000
async function expectImportPrecedesPublicInclusiveInputAssetScan(
mocks: SharedWorkflowImportMocks
): Promise<void> {
await expect(async () => {
const events = mocks.getRequestEvents()
const importIndex = events.indexOf('import')
const afterImportIndex = events.indexOf(
'input-assets-including-public-after-import'
)
expect(
events,
'public-inclusive input assets must not be scanned before import'
).not.toContain('input-assets-including-public-before-import')
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
importIndex
)
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage: ComfyPage,
mocks: SharedWorkflowImportMocks
): Promise<void> {
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
.toEqual([])
}
async function openPanelAndExpectNoMissingMedia(
comfyPage: ComfyPage
): Promise<void> {
const page = comfyPage.page
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeHidden()
const panel = new PropertiesPanelHelper(page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
0
)
}
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
sharedWorkflowImportMocks.resetAndStartRecording()
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup({
clearStorage: false,
url: `/?share=${sharedWorkflowImportScenario.shareId}`
})
})
test('imports shared media before loading workflow so missing media is not surfaced', async ({
comfyPage,
sharedWorkflowImportMocks
}) => {
const { page } = comfyPage
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
await expect
.poll(() =>
page.evaluate(() =>
window.app!.graph.nodes.map((node) => ({
type: node.type,
value: node.widgets?.[0]?.value
}))
)
)
.toEqual([
{
type: 'LoadImage',
value: sharedWorkflowImportScenario.inputFileName
}
])
await expectImportPrecedesPublicInclusiveInputAssetScan(
sharedWorkflowImportMocks
)
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage,
sharedWorkflowImportMocks
)
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
share_id: sharedWorkflowImportScenario.shareId
})
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
await openPanelAndExpectNoMissingMedia(comfyPage)
})
})

View File

@@ -120,13 +120,4 @@ test.describe('Node library sidebar V2', () => {
await expect(options.first()).toBeVisible()
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
})
test('Blueprint previews include description', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.blueprintsTab.click()
await tab.getNode('test blueprint').hover()
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
})

View File

@@ -106,49 +106,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
comfyPage
}) => {
await comfyPage.page.route(
'**/workflows/published/test-share-id',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
share_id: 'test-share-id',
workflow_id: 'wf-1',
name: 'Shared Workflow',
listed: true,
publish_time: new Date().toISOString(),
workflow_json: {
version: 0.4,
nodes: [],
links: [],
groups: [],
config: {},
extra: {}
},
assets: []
})
})
}
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?share=test-share-id'
})
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
@@ -174,51 +131,48 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
test('Falls back to English templates when locale file not found', async ({
comfyPage
}) => {
// Pick a shipped LTR locale and simulate its template index returning 404.
// (Previously this test used 'de', but unsupported locales are now
// clamped to 'en' at boot so they never hit the template fallback path.
// 'fa' would also work but flips document.dir to rtl, which can leak
// into adjacent specs in the same worker.)
const locale = 'tr'
// Set locale to a language that doesn't have a template file
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
await comfyPage.page.route(
`**/templates/index.${locale}.json`,
async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
}
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
)
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
await comfyPage.settings.setSetting('Comfy.Locale', locale)
const localeRequestPromise = comfyPage.page.waitForRequest(
`**/templates/index.${locale}.json`
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
const localeRequest = await localeRequestPromise
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const englishRequest = await englishRequestPromise
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(englishRequest.url()).toContain('templates/index.json')
// Assert on rendered content, not just the container — the container
// testid is present even when the dialog body is empty, which would let
// a regression where the fallback fetch succeeds but no cards render
// pass silently.
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
// Verify English titles are shown as fallback
await expect(
comfyPage.page.getByRole('main').getByText('All Templates')
).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -189,79 +188,4 @@ test.describe('Workflow tabs', () => {
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test.describe('Closing a modified workflow tab (FE-419)', () => {
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
await page.evaluate(() => {
const graph = window.app?.graph
const node = window.LiteGraph?.createNode('Note')
if (graph && node) graph.add(node)
})
await expect(
activeTab.getByTestId('workflow-dirty-indicator')
).toHaveCount(1)
}
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Close anyway' })
).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
0
)
})
test('clicking "Close anyway" closes the tab without saving', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await comfyPage.page
.getByRole('dialog')
.getByRole('button', { name: 'Close anyway' })
.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await expect
.poll(() => topbar.getActiveTabName())
.toContain('Unsaved Workflow')
})
test('dismissing the dialog keeps the modified tab open', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
})
})

View File

@@ -1,65 +1,18 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
clickExactMenuItem,
getNodeRef,
getNodeWrapper,
openContextMenu,
openMultiNodeContextMenu
} from '@e2e/fixtures/utils/contextMenuTestHelpers'
const BYPASS_CLASS = /before:bg-bypass\/60/
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
await comfyPage.contextMenu.clickMenuItemExact(name)
await expect(comfyPage.contextMenu.primeVueMenu).toBeHidden()
}
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await comfyPage.contextMenu.openForVueNode(fixture.header)
return comfyPage.contextMenu.primeVueMenu
}
async function openMultiNodeContextMenu(
comfyPage: ComfyPage,
titles: string[]
) {
// deselectAll via evaluate — clearSelection() clicks at a fixed position
// which can hit nodes or the toolbar overlay
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
await comfyPage.nextFrame()
for (const title of titles) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await fixture.header.click({ modifiers: ['ControlOrMeta'] })
}
await comfyPage.nextFrame()
const firstFixture = await comfyPage.vueNodes.getFixtureByTitle(titles[0])
const box = await firstFixture.header.boundingBox()
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
await comfyPage.page.mouse.click(
box.x + box.width / 2,
box.y + box.height / 2,
{ button: 'right' }
)
const menu = comfyPage.contextMenu.primeVueMenu
await menu.waitFor({ state: 'visible' })
return menu
}
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
return comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.getByTestId(TestIds.node.innerWrapper)
}
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
return refs[0]
}
test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
test.describe('Single Node Actions', () => {
test('should rename node via context menu', async ({ comfyPage }) => {
@@ -75,24 +28,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await expect(renamedNode).toBeVisible()
})
test('should open node info in the right side panel via context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Node Info')
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('should copy and paste node via context menu', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,285 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
clickExactMenuItem,
getNodeRef,
openContextMenu,
openMultiNodeContextMenu
} from '@e2e/fixtures/utils/contextMenuTestHelpers'
test.describe(
'Vue Node Context Menu — Extended Coverage',
{ tag: '@vue-nodes' },
() => {
test.describe('Single Node Actions', () => {
test.fixme('should open node info via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Node Info')
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
})
test('should change node color via Color submenu', async ({
comfyPage
}) => {
const nodeRef = await getNodeRef(comfyPage, 'KSampler')
const initialColor = await nodeRef.getProperty<string | undefined>(
'color'
)
await openContextMenu(comfyPage, 'KSampler')
const menu = comfyPage.contextMenu.primeVueMenu
await menu.getByRole('menuitem', { name: 'Color', exact: true }).click()
const redSwatch = comfyPage.page.getByTitle('Red', { exact: true })
await expect(redSwatch.first()).toBeVisible()
await redSwatch.first().click()
await expect
.poll(() => nodeRef.getProperty<string | undefined>('color'))
.not.toBe(initialColor)
})
test('should change node shape via Shape submenu', async ({
comfyPage
}) => {
const nodeRef = await getNodeRef(comfyPage, 'KSampler')
await openContextMenu(comfyPage, 'KSampler')
const menu = comfyPage.contextMenu.primeVueMenu
await menu.getByRole('menuitem', { name: 'Shape', exact: true }).hover()
const boxItem = menu
.getByRole('menuitem', { name: 'Box', exact: true })
.last()
await expect(boxItem).toBeVisible()
await boxItem.click()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)
})
test('should delete node via Delete context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Delete')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount - 1)
})
test('should not show Run Branch for non-output nodes', async ({
comfyPage
}) => {
const menu = await openContextMenu(comfyPage, 'Load Checkpoint')
await expect(menu).toBeVisible()
await expect(
menu.getByRole('menuitem', {
name: 'Run Branch',
exact: true
})
).toBeHidden()
})
test.fixme('should show Run Branch for output nodes', async ({
comfyPage
}) => {
await expect(
comfyPage.vueNodes.getNodeByTitle('Save Image'),
'Default workflow must contain Save Image node'
).toBeVisible()
await openContextMenu(comfyPage, 'Save Image')
await expect(
comfyPage.contextMenu.primeVueMenu.getByRole('menuitem', {
name: 'Run Branch',
exact: true
})
).toBeVisible()
})
})
test.describe('Image Node Actions', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes(1)
await comfyPage.page
.locator('[data-node-id] img')
.first()
.waitFor({ state: 'visible' })
const [loadImageNode] =
await comfyPage.nodeOps.getNodeRefsByTitle('Load Image')
if (!loadImageNode) throw new Error('Load Image node not found')
await expect
.poll(() =>
comfyPage.page.evaluate(
(nodeId) =>
window.app!.graph.getNodeById(nodeId)?.imgs?.length ?? 0,
loadImageNode.id
)
)
.toBeGreaterThan(0)
})
test('should open mask editor via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
await clickExactMenuItem(comfyPage, 'Open in Mask Editor')
const maskEditorDialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(maskEditorDialog).toBeVisible()
})
})
test.describe('Multi-Node Actions', () => {
const nodeTitles = ['Load Checkpoint', 'KSampler']
test('should align selected nodes via Align Selected To submenu', async ({
comfyPage
}) => {
const nodeRef0 = await getNodeRef(comfyPage, nodeTitles[0])
const nodeRef1 = await getNodeRef(comfyPage, nodeTitles[1])
const initialPos0 = await nodeRef0.getPosition()
const initialPos1 = await nodeRef1.getPosition()
expect(
initialPos0.y !== initialPos1.y,
'Nodes should start at different y positions'
).toBe(true)
await openMultiNodeContextMenu(comfyPage, nodeTitles)
const menu = comfyPage.contextMenu.primeVueMenu
await menu
.getByRole('menuitem', {
name: 'Align Selected To',
exact: true
})
.hover()
const topItem = menu
.getByRole('menuitem', { name: 'Top', exact: true })
.last()
await expect(topItem).toBeVisible()
await topItem.click()
await expect
.poll(async () => {
const pos0 = await nodeRef0.getPosition()
const pos1 = await nodeRef1.getPosition()
return Math.abs(pos0.y - pos1.y)
})
.toBeLessThanOrEqual(1)
})
test('should distribute selected nodes via Distribute Nodes submenu', async ({
comfyPage
}) => {
const threeNodes = ['Load Checkpoint', 'KSampler', 'Empty Latent Image']
await openMultiNodeContextMenu(comfyPage, threeNodes)
const menu = comfyPage.contextMenu.primeVueMenu
await menu
.getByRole('menuitem', {
name: 'Distribute Nodes',
exact: true
})
.hover()
const horizontalItem = menu
.getByRole('menuitem', {
name: 'Horizontal',
exact: true
})
.last()
await expect(horizontalItem).toBeVisible()
await horizontalItem.click()
const nodeRef0 = await getNodeRef(comfyPage, threeNodes[0])
const nodeRef1 = await getNodeRef(comfyPage, threeNodes[1])
const nodeRef2 = await getNodeRef(comfyPage, threeNodes[2])
await expect
.poll(async () => {
const bounds = await Promise.all([
nodeRef0.getBounding(),
nodeRef1.getBounding(),
nodeRef2.getBounding()
])
const sorted = bounds.toSorted((a, b) => a.x - b.x)
const gap1 = sorted[1].x - (sorted[0].x + sorted[0].width)
const gap2 = sorted[2].x - (sorted[1].x + sorted[1].width)
return Math.abs(gap1 - gap2)
})
.toBeLessThanOrEqual(1)
})
})
test.describe('Menu Visibility Invariants', () => {
test('should show Delete menu item for any node', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'KSampler')
await expect(
comfyPage.contextMenu.primeVueMenu.getByRole('menuitem', {
name: 'Delete',
exact: true
})
).toBeVisible()
})
})
test.describe('Widget Extra Options', () => {
test('should show widget-specific options when right-clicking a named widget', async ({
comfyPage
}) => {
const widgetLocator = comfyPage.vueNodes.getWidgetByName(
'KSampler',
'seed'
)
await expect(
widgetLocator,
'KSampler must expose a "seed" widget'
).toBeVisible()
await widgetLocator.hover()
await widgetLocator.dispatchEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2
})
const menu = comfyPage.contextMenu.primeVueMenu
await menu.waitFor({ state: 'visible' })
const menuItems = menu.getByRole('menuitem')
const labels = await menuItems.allTextContents()
const trimmedLabels = labels.map((l) => l.trim())
const hasFavoriteOrRename = trimmedLabels.some(
(label) =>
label.startsWith('Favorite Widget') ||
label.startsWith('Unfavorite Widget') ||
label.startsWith('Rename Widget')
)
expect(
hasFavoriteOrRename,
'Widget-specific menu options (Favorite/Unfavorite/Rename Widget) should appear for the "seed" widget'
).toBe(true)
})
})
}
)

View File

@@ -1,5 +1,3 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -41,19 +39,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
}
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
const box = await button.boundingBox()
if (!box) throw new Error('Tab button has no bounding box')
const start = {
x: box.x + box.width / 2,
y: box.y + box.height * 0.75
}
await comfyPage.canvasOps.dragAndDrop(start, {
x: start.x + 120,
y: start.y + 80
})
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -105,63 +90,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expectPosChanged(headerPos, afterPos)
})
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
await comfyPage.nodeOps.addNode(
'ModelSamplingFlux',
{},
{
x: 500,
y: 200
}
)
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = node.getByText('Show advanced inputs')
const widgets = node.locator('.lg-node-widget')
await expect(showButton).toBeVisible()
await expect(widgets).toHaveCount(2)
const beforePos = await node.boundingBox()
if (!beforePos) throw new Error('Node has no bounding box')
await dragFromTabButton(comfyPage, showButton)
await expect(showButton).toBeVisible()
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
await expect(widgets).toHaveCount(2)
const afterPos = await node.boundingBox()
if (!afterPos) throw new Error('Node missing after drag')
await expectPosChanged(beforePos, afterPos)
})
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const beforePos = await subgraphNode.getPosition()
await dragFromTabButton(
comfyPage,
comfyPage.vueNodes.getSubgraphEnterButton('2')
)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
const afterPos = await subgraphNode.getPosition()
await expectPosChanged(beforePos, afterPos)
})
test('should move all selected nodes together when dragging one with Meta held', async ({
comfyPage
}) => {

View File

@@ -4,7 +4,6 @@ import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
cleanupFakeModel,
dismissErrorOverlay,
@@ -14,9 +13,7 @@ import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
import type { NodeError } from '@/schemas/apiSchema'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -25,61 +22,6 @@ const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
const LOAD_IMAGE_INPUT_NAME = 'image'
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
function buildLoadImageRequiredInputError(): NodeError {
return {
class_type: 'LoadImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
details: '',
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
}
]
}
}
async function surfaceLoadImageMissingInputError(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[loadImageId]: buildLoadImageRequiredInputError()
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
}
async function selectLoadImageNodeForPaste(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(Number(nodeId))
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
window.app!.canvas.selectNode(node)
window.app!.canvas.current_node = node
}, loadImageId)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const loadImageId = String(loadImageNode.id)
return {
loadImageId,
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
}
}
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
@@ -249,74 +191,6 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user drops an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('drop an image onto the Load Image node', async () => {
const dropPosition =
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
if (!dropPosition) {
throw new Error('Load Image node center must be available for drop')
}
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
dropPosition,
waitForUpload: true
})
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user pastes an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('paste an image while Load Image is selected', async () => {
await comfyPage.canvas.focus()
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
)
.toBe('LoadImage')
const uploadResponse = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
// File clipboard contents cannot be seeded reliably in Playwright;
// use the direct document paste mode to exercise usePaste.
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
mode: 'direct'
})
await uploadResponse
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
})
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {

View File

@@ -12,22 +12,19 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
node.widgets!.push(node.widgets![0])
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
node.widgets![2] = node.widgets![0]
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.splice(0, 0, {
...node.widgets![0],
name: 'added_widget_3'
})
node.widgets!.splice(0, 0, node.widgets![0])
})
await expect(loadCheckpointNode).toHaveCount(4)
})

View File

@@ -26,10 +26,6 @@
width: 100%;
height: 100%;
margin: 0;
/* Disable trackpad two-finger horizontal swipe back/forward navigation
and other overscroll gestures. ComfyUI is a full-screen editor; the
browser's overscroll behaviors only ever leave or break the workflow. */
overscroll-behavior: none;
}
body {
display: grid;

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.20",
"version": "1.44.17",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -524,18 +524,9 @@ export type ImportPublishedAssetsRequest = {
*/
published_asset_ids: Array<string>
/**
* Optional. Share ID of the published workflow these assets belong to.
* When provided (non-null, non-empty): all published_asset_ids must
* belong to this share's workflow version; returns
* 400/CodeInvalidAssets if the share is not found or any asset does
* not belong to it.
* When omitted, null, or empty string: no share-scoped validation is
* performed and the assets are validated only against global rules
* (legacy behaviour, preserved for clients that have not yet adopted
* share_id).
*
* The share ID of the published workflow these assets belong to. Required for authorization.
*/
share_id?: string | null
share_id: string
}
/**

View File

@@ -310,8 +310,8 @@ export const zImportPublishedAssetsResponse = z.object({
* Request body for importing assets from a published workflow.
*/
export const zImportPublishedAssetsRequest = z.object({
published_asset_ids: z.array(z.string()),
share_id: z.string().nullish()
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
share_id: z.string().min(1).max(64)
})
/**

View File

@@ -7,8 +7,7 @@
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
"./networkUtil": "./src/networkUtil.ts",
"./piiUtil": "./src/piiUtil.ts"
"./networkUtil": "./src/networkUtil.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"

View File

@@ -3,14 +3,12 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
getFilePathSeparatorVariants,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
joinFilePath,
truncateFilename
} from './formatUtil'
@@ -301,42 +299,6 @@ describe('formatUtil', () => {
})
})
describe('joinFilePath', () => {
it('joins subfolder and filename with normalized slash separators', () => {
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
'nested/folder/child/file.png'
)
})
it('trims boundary separators without changing the filename body', () => {
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
'nested/folder/file.png'
)
})
it('returns the normalized filename when no subfolder is provided', () => {
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
})
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
})
})
describe('getFilePathSeparatorVariants', () => {
it('returns slash and backslash variants for nested paths', () => {
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
'nested/folder/file.png',
'nested\\folder\\file.png'
])
})
it('returns a single value when no separator is present', () => {
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
})
})
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')

View File

@@ -256,31 +256,6 @@ export function isValidUrl(url: string): boolean {
}
}
export function joinFilePath(
subfolder: string | null | undefined,
filename: string | null | undefined
): string {
const normalizedSubfolder = normalizeFilePathSeparators(
subfolder ?? ''
).replace(/^\/+|\/+$/g, '')
const normalizedFilename = normalizeFilePathSeparators(
filename ?? ''
).replace(/^\/+/g, '')
if (!normalizedSubfolder) return normalizedFilename
if (!normalizedFilename) return normalizedSubfolder
return `${normalizedSubfolder}/${normalizedFilename}`
}
export function getFilePathSeparatorVariants(filepath: string): string[] {
const slashPath = normalizeFilePathSeparators(filepath)
const backslashPath = slashPath.replace(/\//g, '\\')
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
}
function normalizeFilePathSeparators(filepath: string): string {
return filepath.replace(/[\\/]+/g, '/')
}
/**
* Parses a filepath into its filename and subfolder components.
*
@@ -299,7 +274,8 @@ export function parseFilePath(filepath: string): {
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
const normalizedPath = normalizeFilePathSeparators(filepath)
const normalizedPath = filepath
.replace(/[\\/]+/g, '/') // Normalize path separators
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash

View File

@@ -1,58 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createPostHogBeforeSend } from './piiUtil'
describe('createPostHogBeforeSend', () => {
const beforeSend = createPostHogBeforeSend()
it('returns null for null input', () => {
expect(beforeSend(null)).toBeNull()
})
it('strips all PII keys from properties, $set, and $set_once', () => {
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: {
email: 'd@example.com',
user_email: 'e@example.com',
$email: 'f@example.com',
name: 'keep me'
},
$set_once: {
email: 'g@example.com',
plan: 'free'
}
}
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).not.toHaveProperty('user_email')
expect(result.$set).not.toHaveProperty('$email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
it('handles missing property bags gracefully', () => {
const event = { properties: { email: 'a@example.com', safe: true } }
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).toHaveProperty('safe', true)
expect(result.$set).toBeUndefined()
expect(result.$set_once).toBeUndefined()
})
})

View File

@@ -1,35 +0,0 @@
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
function stripPiiKeys(obj?: Record<string, unknown>): void {
if (!obj) return
for (const key of PII_KEYS) {
delete obj[key]
}
}
/**
* PostHog before_send hook that strips PII from all three property bags
* an event can carry: properties, $set, and $set_once.
*
* posthog.identify(id, { email }) lands in $set, not properties, so all
* three bags must be sanitized.
*
* Ref: posthog.com/tutorials/web-redact-properties
*/
interface PostHogEventLike {
properties?: Record<string, unknown>
$set?: Record<string, unknown>
$set_once?: Record<string, unknown>
}
export function createPostHogBeforeSend() {
return function beforeSend<E extends PostHogEventLike>(
event: E | null
): E | null {
if (!event) return null
stripPiiKeys(event.properties)
stripPiiKeys(event.$set)
stripPiiKeys(event.$set_once)
return event
}
}

View File

@@ -1,20 +0,0 @@
/**
* Wheel events whose browser default would break the editing experience.
* On macOS trackpads:
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
* recovery short of a page reload.
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
* back/forward navigation, which leaves the workflow.
*
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
* trackpad samples) intentionally falls on the false branch so native
* vertical scroll wins on a tie.
*
* Components that intercept wheel events should suppress the default for
* these gestures even when they otherwise let the browser scroll natively.
*/
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
event.ctrlKey ||
event.metaKey ||
Math.abs(event.deltaX) > Math.abs(event.deltaY)

View File

@@ -1,291 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
const apiMock = vi.hoisted(
() =>
new (class extends EventTarget {
clientId: string | null = 'test-client'
getRawLogs = vi.fn(async () => ({ entries: [{ m: 'log line\n' }] }))
subscribeLogs = vi.fn(async () => {})
})()
)
vi.mock('@/scripts/api', () => ({ api: apiMock }))
const terminalMock = vi.hoisted(() => ({
open: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
reset: vi.fn(),
scrollToBottom: vi.fn(),
onSelectionChange: vi.fn(() => ({ dispose: vi.fn() })),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn()
}))
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
useTerminal: vi.fn(() => ({
terminal: terminalMock,
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
}))
}))
vi.mock('@/components/bottomPanel/tabs/terminal/BaseTerminal.vue', async () => {
const { defineComponent, ref } = await import('vue')
const { useTerminal } =
await import('@/composables/bottomPanelTabs/useTerminal')
return {
default: defineComponent({
emits: ['created'],
setup(_, { emit }) {
const root = ref<HTMLElement | undefined>(undefined)
emit('created', useTerminal(root), root)
return () => null
}
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
logsTerminal: {
loadError:
'Unable to load logs, please ensure you have updated your ComfyUI backend.',
resyncError:
'Unable to resync logs after the backend reconnected. Reopen the console to retry.'
}
}
}
})
const renderLogsTerminal = () =>
render(LogsTerminal, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
stubActions: false,
initialState: { execution: { clientId: 'test-client' } }
}),
i18n
]
}
})
// Silence the production console.error calls in error-path tests. Vitest
// isolates this file's module graph so the spy does not affect other files.
vi.spyOn(console, 'error').mockImplementation(() => {})
// Resolve a getRawLogs call manually to drive deterministic timing in tests
// that need to observe behavior mid-fetch.
const deferredRawLogs = () => {
let resolve!: (value: { entries: { m: string }[] }) => void
let reject!: (err: unknown) => void
const promise = new Promise<{ entries: { m: string }[] }>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('LogsTerminal', () => {
beforeEach(() => {
vi.clearAllMocks()
apiMock.clientId = 'test-client'
})
it('loads logs and subscribes to streaming on mount', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
expect(terminalMock.write).toHaveBeenCalledWith('log line\n')
})
})
it('resyncs, snaps to tail, and re-subscribes on "reconnected"', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.scrollToBottom).toHaveBeenCalledTimes(1)
expect(apiMock.subscribeLogs).toHaveBeenCalledTimes(2)
expect(apiMock.subscribeLogs).toHaveBeenLastCalledWith(true)
})
// The full sequence must be: reset -> write -> scroll -> subscribe
const resetOrder = terminalMock.reset.mock.invocationCallOrder[0]
const writeOrder = terminalMock.write.mock.invocationCallOrder.at(-1)!
const scrollOrder = terminalMock.scrollToBottom.mock.invocationCallOrder[0]
const subscribeOrder =
apiMock.subscribeLogs.mock.invocationCallOrder.at(-1)!
expect(resetOrder).toBeLessThan(writeOrder)
expect(writeOrder).toBeLessThan(scrollOrder)
expect(scrollOrder).toBeLessThan(subscribeOrder)
})
it('aborts an in-flight resync when a second "reconnected" arrives', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
// First resync hangs on getRawLogs
const first = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => first.promise)
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
})
// Second resync resolves immediately
apiMock.getRawLogs.mockImplementationOnce(async () => ({
entries: [{ m: 'fresh\n' }]
}))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
})
// Now resolve the first (aborted) resync — none of its side effects must apply
first.resolve({ entries: [{ m: 'stale\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.write).not.toHaveBeenCalledWith('stale\n')
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
})
it('aborts an in-flight mount fetch when "reconnected" arrives first', async () => {
// Mount's getRawLogs hangs so we can drive the race deterministically.
const mount = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => mount.promise)
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
// Resync wins the race and writes the post-reboot snapshot.
apiMock.getRawLogs.mockImplementationOnce(async () => ({
entries: [{ m: 'fresh\n' }]
}))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
})
// Mount's late response must not stomp on the freshly-reset terminal.
mount.resolve({ entries: [{ m: 'stale-mount\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.write).not.toHaveBeenCalledWith('stale-mount\n')
})
it('surfaces an inline error when the resync fetch fails', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to resync logs')
})
})
it('shows a load error when the initial fetch fails', async () => {
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
renderLogsTerminal()
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to load logs')
})
})
it('recovers from an initial load failure when a reconnect arrives', async () => {
apiMock.getRawLogs
.mockRejectedValueOnce(new Error('initial fail'))
.mockResolvedValueOnce({ entries: [{ m: 'recovered\n' }] })
renderLogsTerminal()
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to load logs')
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(screen.queryByTestId('terminal-error-message')).toBeNull()
expect(screen.queryByTestId('terminal-loading-spinner')).toBeNull()
expect(terminalMock.write).toHaveBeenCalledWith('recovered\n')
})
})
it('cleans up listeners and unsubscribes on unmount', async () => {
const { unmount } = renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
unmount()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(false)
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await nextTick()
expect(terminalMock.reset).not.toHaveBeenCalled()
// No additional getRawLogs beyond the mount-time call
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
it('does not write to the terminal when unmount happens mid-fetch', async () => {
const pending = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => pending.promise)
const { unmount } = renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
unmount()
pending.resolve({ entries: [{ m: 'late\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.write).not.toHaveBeenCalled()
})
})

View File

@@ -12,36 +12,79 @@
data-testid="terminal-loading-spinner"
class="relative inset-0 z-10 flex h-full items-center justify-center"
/>
<BaseTerminal
v-show="!loading && !errorMessage"
@created="terminalCreated"
/>
<BaseTerminal v-show="!loading" @created="terminalCreated" />
</div>
</template>
<script setup lang="ts">
import type { Terminal } from '@xterm/xterm'
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import type { Ref } from 'vue'
import { shallowRef } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { useLogsTerminal } from '@/composables/bottomPanelTabs/useLogsTerminal'
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
import BaseTerminal from './BaseTerminal.vue'
const terminal = shallowRef<Terminal>()
const { errorMessage, loading } = useLogsTerminal(terminal)
const errorMessage = ref('')
const loading = ref(true)
const terminalCreated = (
{ terminal: instance, useAutoSize }: ReturnType<typeof useTerminal>,
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
// Auto-size terminal to fill container width.
// minCols: 80 ensures minimum width for colab environments.
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
terminal.value = instance
const update = (entries: Array<LogEntry>) => {
terminal.write(entries.map((e) => e.m).join(''))
}
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
update(e.detail.entries)
}
const loadLogEntries = async () => {
const logs = await api.getRawLogs()
update(logs.entries)
}
const watchLogs = async () => {
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) {
await until(clientId).not.toBeNull()
}
await api.subscribeLogs(true)
api.addEventListener('logs', logReceived)
}
onMounted(async () => {
try {
await loadLogEntries()
} catch (err) {
console.error('Error loading logs', err)
// On older backends the endpoints won't exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
return
}
await watchLogs()
loading.value = false
})
onUnmounted(async () => {
if (api.clientId) {
await api.subscribeLogs(false)
}
api.removeEventListener('logs', logReceived)
})
}
</script>

View File

@@ -40,10 +40,7 @@
<template #contentFilter>
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex flex-wrap gap-2"
>
<div class="flex flex-wrap gap-2">
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
@@ -51,7 +48,6 @@
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -66,7 +62,6 @@
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -81,7 +76,6 @@
v-model="selectedRunsOnObjects"
:label="runsOnFilterLabel"
:options="runsOnOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -98,7 +92,6 @@
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
:content-style="selectContentStyle"
class="w-62.5"
>
<template #icon>
@@ -423,7 +416,6 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -640,8 +632,6 @@ const selectedRunsOnObjects = computed({
const loadingTemplate = ref<string | null>(null)
const hoveredTemplate = ref<string | null>(null)
const cardRefs = ref<HTMLElement[]>([])
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)

View File

@@ -1,192 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { close: 'Close' } } },
missingWarn: false,
fallbackWarn: false
})
const Body = defineComponent({
name: 'Body',
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
})
function mountDialog() {
return render(GlobalDialog, {
global: { plugins: [PrimeVue, i18n] }
})
}
describe('GlobalDialog renderer branching', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
it('renders the PrimeVue branch when renderer is omitted', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'primevue-default',
title: 'PrimeVue dialog',
component: Body
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
})
it('renders the Reka branch when renderer is reka', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-opt-in',
title: 'Reka dialog',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.length).toBeGreaterThan(0)
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
})
it('preserves the renderer flag on the dialog stack item', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-flag-check',
title: 'Reka',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
const item = store.dialogStack.find((d) => d.key === 'reka-flag-check')
expect(item?.dialogComponentProps.renderer).toBe('reka')
})
})
describe('GlobalDialog Reka parity with PrimeVue', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
it('omits the close button when closable is false', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-not-closable',
title: 'No close',
component: Body,
dialogComponentProps: { renderer: 'reka', closable: false }
})
await screen.findByRole('dialog')
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
})
it('renders the close button by default', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-closable',
title: 'Closable',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
it('omits the title when headless is true', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-headless',
title: 'Hidden title',
component: Body,
dialogComponentProps: { renderer: 'reka', headless: true }
})
await screen.findByRole('dialog')
expect(screen.queryByText('Hidden title')).toBeNull()
})
it('renders the title when headless is omitted', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-titled',
title: 'Visible title',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
expect(screen.getByText('Visible title')).toBeInTheDocument()
})
it('closes the dialog on Escape by default', async () => {
mountDialog()
const store = useDialogStore()
const user = userEvent.setup()
store.showDialog({
key: 'reka-esc-default',
title: 'Esc closes',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
await user.keyboard('{Escape}')
expect(store.isDialogOpen('reka-esc-default')).toBe(false)
})
it('does not close on Escape when closable is false', async () => {
mountDialog()
const store = useDialogStore()
const user = userEvent.setup()
store.showDialog({
key: 'reka-esc-blocked',
title: 'Esc blocked',
component: Body,
dialogComponentProps: { renderer: 'reka', closable: false }
})
await screen.findByRole('dialog')
await user.keyboard('{Escape}')
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
})
})

View File

@@ -1,106 +1,49 @@
<!-- The main global dialog to show various things -->
<template>
<template v-for="item in dialogStore.dialogStack" :key="item.key">
<Dialog
v-if="isRekaItem(item)"
:open="item.visible"
:modal="item.dialogComponentProps.modal ?? true"
@update:open="(open) => onRekaOpenChange(item.key, open)"
>
<DialogPortal>
<DialogOverlay />
<DialogContent
:size="item.dialogComponentProps.size ?? 'md'"
:aria-labelledby="item.key"
@escape-key-down="
(e) =>
item.dialogComponentProps.closeOnEscape === false &&
e.preventDefault()
"
@pointer-down-outside="
(e) =>
item.dialogComponentProps.dismissableMask === false &&
e.preventDefault()
"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
<PrimeDialog
v-else
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<Dialog
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</PrimeDialog>
</template>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import PrimeDialog from 'primevue/dialog'
import Dialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
import type { DialogComponentProps } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
@@ -110,14 +53,6 @@ const teamWorkspacesEnabled = computed(
const dialogStore = useDialogStore()
function isRekaItem(item: DialogInstance) {
return item.dialogComponentProps.renderer === 'reka'
}
function onRekaOpenChange(key: string, open: boolean) {
if (!open) dialogStore.closeDialog({ key })
}
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -43,43 +42,4 @@ describe('ConfirmationDialogContent', () => {
renderComponent({ message: longFilename })
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('omits the Cancel button when type is dirtyClose', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
expect(screen.getByText('g.save')).toBeInTheDocument()
})
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({ type: 'dirtyClose', onConfirm })
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
it('falls back to "no" label when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByText('g.no')).toBeInTheDocument()
})
})

View File

@@ -55,7 +55,7 @@
</div>
<Button
v-if="type !== 'info' && type !== 'dirtyClose'"
v-if="type !== 'info'"
variant="secondary"
autofocus
@click="onCancel"
@@ -86,9 +86,9 @@
<template v-else-if="type === 'dirtyClose'">
<Button variant="secondary" @click="onDeny">
<i class="pi pi-times" />
{{ denyLabel ?? $t('g.no') }}
{{ $t('g.no') }}
</Button>
<Button autofocus @click="onConfirm">
<Button @click="onConfirm">
<i class="pi pi-save" />
{{ $t('g.save') }}
</Button>
@@ -131,7 +131,6 @@ const props = defineProps<{
onConfirm: (value?: boolean) => void
itemList?: string[]
hint?: string
denyLabel?: string
}>()
const { t } = useI18n()

View File

@@ -1,8 +1,5 @@
<template>
<div
:ref="primeVueOverlay.overlayScopeRef"
class="keybinding-panel flex flex-col gap-2"
>
<div class="keybinding-panel flex flex-col gap-2">
<Teleport defer to="#keybinding-panel-header">
<SearchInput
v-model="filters['global'].value"
@@ -18,12 +15,10 @@
<div class="flex items-center gap-2">
<KeybindingPresetToolbar
:preset-names="presetNames"
:content-style="keybindingOverlayContentStyle"
@presets-changed="refreshPresetList"
/>
<DropdownMenu
:entries="menuEntries"
:style="keybindingOverlayContentStyle"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
button-size="unset"
@@ -198,12 +193,11 @@
</template>
</Column>
<template #expansion="slotProps">
<div class="pl-4" data-testid="keybinding-expansion-content">
<div class="pl-4">
<div
v-for="(binding, idx) in (slotProps.data as ICommandData)
.keybindings"
:key="binding.combo.serialize()"
data-testid="keybinding-expansion-binding"
class="flex items-center justify-between border-b border-border-subtle py-1.5 last:border-b-0"
>
<div class="flex items-center gap-4">
@@ -243,7 +237,6 @@
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
@@ -320,7 +313,6 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
@@ -344,8 +336,6 @@ const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
const presetNames = ref<string[]>([])

View File

@@ -9,10 +9,7 @@
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent
:style="contentStyle"
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
>
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
<div class="max-w-60">
<SelectItem
value="default"
@@ -49,7 +46,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -61,9 +57,8 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames, contentStyle } = defineProps<{
const { presetNames } = defineProps<{
presetNames: string[]
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

@@ -10,7 +10,7 @@
<a
v-bind="props.action"
class="flex items-center gap-2 px-3 py-1.5"
@click="onItemClick($event, item)"
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
>
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
<span class="flex-1">{{ item.label }}</span>
@@ -21,27 +21,20 @@
{{ item.shortcut }}
</span>
<i
v-if="hasSubmenu || item.isColorSubmenu || item.isShapeSubmenu"
v-if="hasSubmenu || item.isColorSubmenu"
class="icon-[lucide--chevron-right] size-4 opacity-60"
/>
</a>
</template>
</ContextMenu>
<SubmenuPopover
<!-- Color picker menu (custom with color circles) -->
<ColorPickerMenu
v-if="colorOption"
ref="colorSubmenu"
key="color-submenu"
ref="colorPickerMenu"
key="color-picker-menu"
:option="colorOption"
@submenu-click="handleSubmenuSelect"
/>
<SubmenuPopover
v-if="shapeOption"
ref="shapeSubmenu"
key="shape-submenu"
:option="shapeOption"
@submenu-click="handleSubmenuSelect"
@submenu-click="handleColorSelect"
/>
</template>
@@ -61,18 +54,16 @@ import type {
} from '@/composables/graph/useMoreOptionsMenu'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import SubmenuPopover from './selectionToolbox/SubmenuPopover.vue'
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
interface ExtendedMenuItem extends MenuItem {
isColorSubmenu?: boolean
isShapeSubmenu?: boolean
shortcut?: string
originalOption?: MenuOption
}
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const colorSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
const shapeSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
const isOpen = ref(false)
const { menuOptions, bump } = useMoreOptionsMenu()
@@ -159,20 +150,21 @@ useEventListener(
{ passive: true }
)
// Find color picker option
const colorOption = computed(() =>
menuOptions.value.find((opt) => opt.isColorPicker)
)
const shapeOption = computed(() =>
menuOptions.value.find((opt) => opt.isShapePicker)
)
// Check if option is the color picker
function isColorOption(option: MenuOption): boolean {
return Boolean(option.isColorPicker)
}
// Convert MenuOption to PrimeVue MenuItem
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
if (option.type === 'divider') return { separator: true }
const isColor = Boolean(option.isColorPicker)
const isShape = Boolean(option.isShapePicker)
const usesPopover = isColor || isShape
const isColor = isColorOption(option)
const item: ExtendedMenuItem = {
label: option.label,
@@ -180,14 +172,11 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
disabled: option.disabled,
shortcut: option.shortcut,
isColorSubmenu: isColor,
isShapeSubmenu: isShape,
originalOption: option
}
// Submenus opened via popover (color, shape) deliberately omit `items` so
// PrimeVue does not render a nested <ul> inside the scrollable root list,
// which would be clipped when the menu overflows the viewport (FE-570).
if (option.hasSubmenu && option.submenu && !usesPopover) {
// Native submenus for non-color options
if (option.hasSubmenu && option.submenu && !isColor) {
item.items = option.submenu.map((sub) => ({
label: sub.label,
icon: sub.icon,
@@ -199,6 +188,7 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
}))
}
// Regular action items
if (!option.hasSubmenu && option.action) {
item.command = () => {
option.action?.()
@@ -255,30 +245,17 @@ function toggle(event: Event) {
defineExpose({ toggle, hide, isOpen, show })
function onItemClick(event: MouseEvent, item: ExtendedMenuItem) {
if (item.isColorSubmenu) {
openSubmenuPopover(event, colorSubmenu.value, shapeSubmenu.value)
} else if (item.isShapeSubmenu) {
openSubmenuPopover(event, shapeSubmenu.value, colorSubmenu.value)
}
}
function openSubmenuPopover(
event: MouseEvent,
target: InstanceType<typeof SubmenuPopover> | undefined,
other: InstanceType<typeof SubmenuPopover> | undefined
) {
if (!target) return
function showColorPopover(event: MouseEvent) {
event.stopPropagation()
event.preventDefault()
other?.hide()
const anchor = Array.from((event.currentTarget as HTMLElement).children).find(
const target = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
target.toggle(event, anchor)
colorPickerMenu.value?.toggle(event, target)
}
function handleSubmenuSelect(subOption: SubMenuOption) {
// Handle color selection
function handleColorSelect(subOption: SubMenuOption) {
subOption.action()
hide()
}
@@ -293,17 +270,11 @@ function constrainMenuHeight() {
if (!rootList) return
const rect = rootList.getBoundingClientRect()
const availableHeight = window.innerHeight - rect.top - 8
if (availableHeight <= 0) return
// Setting overflow-y to auto/scroll on the root <ul> coerces overflow-x
// to a non-visible value too (CSS spec), which clips horizontally-opening
// submenus like Shape. Only apply the constraint when content truly
// overflows so the common case keeps overflow visible.
if (rootList.scrollHeight <= availableHeight) return
rootList.style.maxHeight = `${availableHeight}px`
rootList.style.overflowY = 'auto'
const maxHeight = window.innerHeight - rect.top - 8
if (maxHeight > 0) {
rootList.style.maxHeight = `${maxHeight}px`
rootList.style.overflowY = 'auto'
}
}
function onMenuShow() {

View File

@@ -1,222 +0,0 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { i18n, te } from '@/i18n'
import type * as LiteGraphModule from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import NodeTooltip from './NodeTooltip.vue'
type HitTest = (
node: MockNode,
x: number,
y: number,
offset: [number, number]
) => number
interface MockWidget {
name: string
tooltip?: string
}
interface MockNode {
type: string
flags: {
collapsed?: boolean
ghost?: boolean
}
pos: [number, number]
inputs: Array<{ name: string }>
constructor: {
title_mode?: 0 | 1 | 2 | 3
}
}
interface MockCanvas {
mouse: [number, number]
graph_mouse: [number, number]
node_over: MockNode | null
getWidgetAtCursor: () => MockWidget | null
}
const mockIsOverNodeInput = vi.hoisted(() => vi.fn<HitTest>())
const mockIsOverNodeOutput = vi.hoisted(() => vi.fn<HitTest>())
const mockIsDOMWidget = vi.hoisted(() =>
vi.fn<(widget: MockWidget) => boolean>()
)
const mockCanvas = vi.hoisted(
(): MockCanvas => ({
mouse: [100, 80],
graph_mouse: [10, 10],
node_over: null,
getWidgetAtCursor: vi.fn<() => MockWidget | null>()
})
)
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal<typeof LiteGraphModule>()
return {
...actual,
isOverNodeInput: mockIsOverNodeInput,
isOverNodeOutput: mockIsOverNodeOutput
}
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: mockCanvas
}
}))
vi.mock('@/scripts/domWidget', () => ({
isDOMWidget: mockIsDOMWidget
}))
const jsonTooltip =
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
const positiveCoordsTooltipKey =
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const sam3DetectNodeDef: ComfyNodeDef = {
name: 'SAM3_Detect',
display_name: 'SAM3 Detect',
category: 'detection/',
python_module: 'comfy_extras.nodes_sam3',
description: '',
input: {
required: {},
optional: {
positive_coords: [
'STRING',
{
tooltip: jsonTooltip,
forceInput: true
}
]
}
},
output: ['MASK'],
output_name: ['masks'],
output_tooltips: [jsonTooltip],
output_node: false,
deprecated: false,
experimental: false
}
function createSam3Node(): MockNode {
return {
type: 'SAM3_Detect',
flags: {},
pos: [0, 0],
inputs: [{ name: 'positive_coords' }],
constructor: {}
}
}
function mergeOutputTooltipMessage(tooltip: string | null) {
i18n.global.mergeLocaleMessage('en', {
nodeDefs: {
SAM3_Detect: {
outputs: {
0: {
tooltip
}
}
}
}
})
}
async function renderAndHoverCanvas() {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(NodeTooltip)
const canvas = document.createElement('canvas')
document.body.appendChild(canvas)
await user.hover(canvas)
await vi.runOnlyPendingTimersAsync()
await nextTick()
}
describe('NodeTooltip', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(useSettingStore(), 'get').mockImplementation(
<K extends keyof Settings>(key: K): Settings[K] => {
switch (key) {
case 'LiteGraph.Node.TooltipDelay':
return 0 as Settings[K]
default:
return undefined as Settings[K]
}
}
)
mockCanvas.mouse = [100, 80]
mockCanvas.graph_mouse = [10, 10]
mockCanvas.node_over = createSam3Node()
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue(null)
vi.mocked(mockIsOverNodeInput).mockReturnValue(-1)
vi.mocked(mockIsOverNodeOutput).mockReturnValue(-1)
vi.mocked(mockIsDOMWidget).mockReturnValue(false)
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
mergeOutputTooltipMessage(jsonTooltip)
})
afterEach(() => {
mergeOutputTooltipMessage(null)
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('shows input slot JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockIsOverNodeInput).mockReturnValue(0)
await renderAndHoverCanvas()
expect(te(positiveCoordsTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
it('shows output slot JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockIsOverNodeOutput).mockReturnValue(0)
await renderAndHoverCanvas()
expect(te(outputTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
it('shows widget JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue({
name: 'positive_coords'
})
await renderAndHoverCanvas()
expect(te(positiveCoordsTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
})

View File

@@ -13,7 +13,7 @@
import { useEventListener } from '@vueuse/core'
import { nextTick, ref } from 'vue'
import { stRaw } from '@/i18n'
import { st } from '@/i18n'
import {
LiteGraph,
isOverNodeInput,
@@ -84,7 +84,7 @@ function onIdle() {
)
if (inputSlot !== -1) {
const inputName = node.inputs[inputSlot].name
const translatedTooltip = stRaw(
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
nodeDef?.inputs[inputName]?.tooltip ?? ''
)
@@ -98,7 +98,7 @@ function onIdle() {
[0, 0]
)
if (outputSlot !== -1) {
const translatedTooltip = stRaw(
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
)
@@ -108,7 +108,7 @@ function onIdle() {
const widget = comfyApp.canvas.getWidgetAtCursor()
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !isDOMWidget(widget)) {
const translatedTooltip = stRaw(
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef?.inputs[widget.name]?.tooltip ?? ''
)

View File

@@ -1,7 +1,6 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { fireEvent, render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -30,26 +29,6 @@ function createMockExtensionService(): ReturnType<typeof useExtensionService> {
>
}
const { settingGetMock } = vi.hoisted(() => ({
settingGetMock: vi.fn()
}))
const defaultSettingValues: Record<string, unknown> = {
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': true,
'Comfy.Load3D.3DViewerEnable': true
}
function mockSettingValues(overrides: Record<string, unknown> = {}) {
const settingValues = {
...defaultSettingValues,
...overrides
}
settingGetMock.mockImplementation(
(key: string): unknown => settingValues[key] ?? null
)
}
// Mock the composables and services
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
@@ -100,7 +79,10 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: settingGetMock
get: vi.fn((key: string) => {
if (key === 'Comfy.Load3D.3DViewerEnable') return true
return null
})
})
}))
@@ -146,7 +128,7 @@ describe('SelectionToolbox', () => {
}
beforeEach(() => {
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
setActivePinia(createPinia())
canvasStore = useCanvasStore()
nodeDefMock = {
type: 'TestNode',
@@ -157,7 +139,6 @@ describe('SelectionToolbox', () => {
canvasStore.canvas = createMockCanvas()
vi.resetAllMocks()
mockSettingValues()
})
function renderComponent(props = {}): { container: Element } {
@@ -250,42 +231,6 @@ describe('SelectionToolbox', () => {
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should not show info button when legacy menu uses the new node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': true
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should not show info button when legacy menu uses the legacy node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': false
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should show info button when new menu uses the legacy node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': false
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeTruthy()
})
it('should show color picker for all selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]

View File

@@ -16,8 +16,8 @@
@wheel="canvasInteractions.forwardEventToCanvas"
>
<DeleteButton v-if="showDelete" />
<VerticalDivider v-if="canOpenNodeInfo && showAnyPrimaryActions" />
<InfoButton v-if="canOpenNodeInfo" />
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
<InfoButton v-if="showInfoButton" />
<ColorPickerButton v-if="showColorPicker" />
<FrameNodes v-if="showFrameNodes" />
@@ -105,8 +105,9 @@ const {
isSingleImageNode,
hasAny3DNodeSelected,
hasOutputNodesSelected,
canOpenNodeInfo
nodeDef
} = useSelectionState()
const showInfoButton = computed(() => !!nodeDef.value)
const showColorPicker = computed(() => hasAnySelection.value)
const showConvertToSubgraph = computed(() => hasAnySelection.value)

View File

@@ -8,7 +8,7 @@
unstyled
:pt="{
root: {
class: 'p-popover absolute z-60'
class: 'absolute z-60'
},
content: {
class: [
@@ -90,12 +90,8 @@ const popoverRef = ref<InstanceType<typeof Popover>>()
const toggle = (event: Event, target?: HTMLElement) => {
popoverRef.value?.toggle(event, target)
}
const hide = () => {
popoverRef.value?.hide()
}
defineExpose({
toggle,
hide
toggle
})
const handleSubmenuClick = (subOption: SubMenuOption) => {

View File

@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -8,20 +9,19 @@ import { createI18n } from 'vue-i18n'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
import Button from '@/components/ui/button/Button.vue'
const { openNodeInfoMock, trackUiButtonClickedMock } = vi.hoisted(() => ({
openNodeInfoMock: vi.fn(),
trackUiButtonClickedMock: vi.fn()
const { openPanelMock } = vi.hoisted(() => ({
openPanelMock: vi.fn()
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => ({
openNodeInfo: openNodeInfoMock
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({
openPanel: openPanelMock
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: trackUiButtonClickedMock
trackUiButtonClicked: vi.fn()
})
}))
@@ -39,8 +39,8 @@ describe('InfoButton', () => {
})
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
openNodeInfoMock.mockReturnValue(true)
})
const renderComponent = () => {
@@ -53,29 +53,12 @@ describe('InfoButton', () => {
})
}
const clickNodeInfoButton = async () => {
it('should open the info panel on click', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Node Info' }))
}
it('should open the node info panel on click', async () => {
renderComponent()
await clickNodeInfoButton()
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened'
})
})
it('should not track the click when the node info panel is unavailable', async () => {
openNodeInfoMock.mockReturnValue(false)
renderComponent()
await clickNodeInfoButton()
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).not.toHaveBeenCalled()
expect(openPanelMock).toHaveBeenCalledWith('info')
})
})

View File

@@ -15,16 +15,18 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
const { openNodeInfo } = useSelectionState()
const rightSidePanelStore = useRightSidePanelStore()
/**
* Track node info button click and toggle node help.
*/
const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
})
rightSidePanelStore.openPanel('info')
}
</script>

View File

@@ -21,42 +21,20 @@
</Button>
<Select
:model-value="selectedSpeed != null ? String(selectedSpeed) : undefined"
@update:model-value="(val) => (selectedSpeed = Number(val))"
>
<SelectTrigger size="md" class="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in speedOptions"
:key="opt.value"
:value="String(opt.value)"
>
{{ opt.name }}
</SelectItem>
</SelectContent>
</Select>
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
/>
<Select
:model-value="
selectedAnimation != null ? String(selectedAnimation) : undefined
"
@update:model-value="(val) => (selectedAnimation = Number(val))"
>
<SelectTrigger size="md" class="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="anim in animations"
:key="anim.index"
:value="String(anim.index)"
>
{{ anim.name }}
</SelectItem>
</SelectContent>
</Select>
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
</div>
<div class="flex w-full max-w-xs items-center gap-2 px-4">
@@ -76,14 +54,10 @@
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import Slider from '@/components/ui/slider/Slider.vue'
type Animation = { name: string; index: number }

View File

@@ -5,20 +5,20 @@ import { ref } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
vi.mock('@/components/ui/slider/Slider.vue', () => ({
vi.mock('primevue/slider', () => ({
default: {
name: 'UiSlider',
name: 'Slider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:value="modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', [Number($event.target.value)])"
@input="$emit('update:modelValue', Number($event.target.value))"
/>
`
}

View File

@@ -15,22 +15,21 @@
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
>
<Slider
:model-value="sliderValue"
v-model="value"
class="w-full"
:min="min"
:max="max"
:step="step"
@update:model-value="onSliderUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Slider from 'primevue/slider'
import { onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
const {
icon = 'pi-expand',
@@ -48,12 +47,6 @@ const {
const value = defineModel<number>()
const showSlider = ref(false)
const sliderValue = computed(() => [value.value ?? min])
function onSliderUpdate(val: number[] | undefined) {
if (val?.length) value.value = val[0]
}
const toggleSlider = () => {
showSlider.value = !showSlider.value
}

View File

@@ -7,81 +7,38 @@ import { createI18n } from 'vue-i18n'
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
vi.mock('primevue/select', () => ({
default: {
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
}
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
vi.mock('primevue/slider', () => ({
default: {
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
name: 'Slider',
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:value="modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', [Number($event.target.value)])"
:aria-label="ariaLabel"
@input="$emit('update:modelValue', Number($event.target.value))"
/>
`
}

View File

@@ -2,46 +2,34 @@
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ t('load3d.viewer.cameraType') }}</label>
<Select v-model="cameraType">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="cam in cameras"
:key="cam.value"
:value="cam.value"
>
{{ cam.title }}
</SelectItem>
</SelectContent>
<Select
v-model="cameraType"
:options="cameras"
option-label="title"
option-value="value"
>
</Select>
</div>
<div v-if="showFOVButton" class="flex flex-col gap-2">
<label>{{ t('load3d.fov') }}</label>
<Slider
:model-value="fovSliderValue"
v-model="fov"
:min="10"
:max="150"
:step="1"
:aria-label="t('load3d.fov')"
@update:model-value="onFovUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import Slider from 'primevue/slider'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
const { t } = useI18n()
@@ -53,10 +41,4 @@ const cameras = [
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const showFOVButton = computed(() => cameraType.value === 'perspective')
const fovSliderValue = computed(() => [fov.value ?? 10])
function onFovUpdate(val: number[] | undefined) {
if (val?.length) fov.value = val[0]
}
</script>

View File

@@ -5,67 +5,24 @@ import { createI18n } from 'vue-i18n'
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
vi.mock('primevue/select', () => ({
default: {
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
}
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -1,18 +1,11 @@
<template>
<div class="space-y-4">
<Select v-model="exportFormat">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="fmt in exportFormats"
:key="fmt.value"
:value="fmt.value"
>
{{ fmt.label }}
</SelectItem>
</SelectContent>
<Select
v-model="exportFormat"
:options="exportFormats"
option-label="label"
option-value="value"
>
</Select>
<Button
@@ -26,14 +19,10 @@
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
const emit = defineEmits<{
(e: 'exportModel', format: string): void

View File

@@ -17,20 +17,19 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
vi.mock('primevue/slider', () => ({
default: {
name: 'UiSlider',
name: 'Slider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:value="modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', [Number($event.target.value)])"
@input="$emit('update:modelValue', Number($event.target.value))"
/>
`
}

View File

@@ -3,20 +3,18 @@
<label>{{ $t('load3d.lightIntensity') }}</label>
<Slider
:model-value="sliderValue"
v-model="lightIntensity"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
@update:model-value="onSliderUpdate"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Slider from 'primevue/slider'
import Slider from '@/components/ui/slider/Slider.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
@@ -30,12 +28,4 @@ const lightIntensityMinimum = useSettingStore().get(
const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
const sliderValue = computed(() => [
lightIntensity.value ?? lightIntensityMinimum
])
function onSliderUpdate(val: number[] | undefined) {
if (val?.length) lightIntensity.value = val[0]
}
</script>

View File

@@ -9,67 +9,22 @@ import type {
UpDirection
} from '@/extensions/core/load3d/interfaces'
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
vi.mock('primevue/select', () => ({
default: {
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
`
}
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -2,51 +2,31 @@
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ $t('load3d.upDirection') }}</label>
<Select v-model="upDirection">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in upDirectionOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="upDirection"
:options="upDirectionOptions"
option-label="label"
option-value="value"
/>
</div>
<div v-if="materialModes.length > 0" class="flex flex-col gap-2">
<label>{{ $t('load3d.materialMode') }}</label>
<Select v-model="materialMode">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in materialModeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="materialMode"
:options="materialModeOptions"
option-label="label"
option-value="value"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type {
MaterialMode,
UpDirection

View File

@@ -7,15 +7,9 @@
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
</div>
<div class="flex items-center gap-2">
<input
id="showGrid"
v-model="showGrid"
type="checkbox"
name="showGrid"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="showGrid" class="cursor-pointer">
<div>
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
<label for="showGrid" class="pl-2">
{{ $t('load3d.showGrid') }}
</label>
</div>
@@ -64,6 +58,7 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -12,7 +12,7 @@
</span>
<span
v-if="rest"
class="-ml-2.5 flex h-5 max-w-max min-w-0 grow basis-0 items-center truncate rounded-r-full bg-component-node-widget-background text-xs"
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
>
<span class="pr-2" v-text="rest" />
</span>

View File

@@ -2,7 +2,6 @@
<div
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
data-testid="node-preview-card"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div

View File

@@ -252,20 +252,6 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('Log Out')).toBeInTheDocument()
})
describe('credits help icon (FE-617)', () => {
it('renders the credits help icon as an interactive button with the unified-credits tooltip as its accessible name', () => {
renderComponent()
const helpButton = screen.getByTestId('credits-info-button')
expect(helpButton).toBeInTheDocument()
expect(helpButton.tagName).toBe('BUTTON')
expect(helpButton).toHaveAttribute(
'aria-label',
enMessages.credits.unified.tooltip
)
})
})
it('opens user settings and emits close event when settings item is clicked', async () => {
const { user, onClose } = renderComponent()

View File

@@ -41,16 +41,10 @@
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
<Button
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
variant="muted-textonly"
size="icon-sm"
class="mr-auto"
:aria-label="$t('credits.unified.tooltip')"
data-testid="credits-info-button"
>
<i class="icon-[lucide--circle-help]" />
</Button>
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
/>
<Button
v-if="isCloud && isFreeTier"
variant="gradient"

View File

@@ -23,7 +23,6 @@
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
data-testid="workflow-dirty-indicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
>

View File

@@ -1,188 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { FOR_STORIES } from '@/components/ui/dialog/dialog.variants'
const { sizes } = FOR_STORIES
const meta: Meta = {
title: 'Components/Dialog/Dialog',
tags: ['autodocs'],
argTypes: {
size: {
control: { type: 'select' },
options: sizes,
defaultValue: 'md'
}
},
args: {
size: 'md'
}
}
export default meta
type Story = StoryObj
export const Default: Story = {
render: (args) => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose
},
setup() {
const open = ref(false)
return { args, open }
},
template: `
<Button @click="open = true">Open dialog</Button>
<Dialog v-model:open="open">
<DialogPortal>
<DialogOverlay />
<DialogContent :size="args.size">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="px-4 py-2">
<DialogDescription>
This action cannot be undone. The selected items will be permanently removed.
</DialogDescription>
</div>
<DialogFooter>
<Button variant="textonly" @click="open = false">Cancel</Button>
<Button variant="destructive" @click="open = false">Delete</Button>
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}
export const LongContent: Story = {
render: (args) => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose
},
setup() {
const open = ref(false)
return { args, open }
},
template: `
<Button @click="open = true">Open long content</Button>
<Dialog v-model:open="open">
<DialogPortal>
<DialogOverlay />
<DialogContent :size="args.size">
<DialogHeader>
<DialogTitle>Long content scrolls</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="px-4 py-2 space-y-2 overflow-auto">
<p v-for="n in 30" :key="n">
Paragraph {{ n }} — the dialog body should scroll independently
while the header and footer stay pinned.
</p>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}
export const Headless: Story = {
render: () => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent
},
setup() {
const open = ref(false)
return { open }
},
template: `
<Button @click="open = true">Open headless</Button>
<Dialog v-model:open="open">
<DialogPortal>
<DialogOverlay />
<DialogContent size="sm" class="p-6">
<p class="text-sm">No header, no footer — fully custom content.</p>
<Button class="mt-4" @click="open = false">Close</Button>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose
},
setup() {
const openSize = ref<string | null>(null)
return { openSize, sizes }
},
template: `
<div class="flex gap-2 flex-wrap">
<Button v-for="s in sizes" :key="s" @click="openSize = s">{{ s }}</Button>
</div>
<Dialog
v-for="s in sizes"
:key="s"
:open="openSize === s"
@update:open="(o) => { if (!o) openSize = null }"
>
<DialogPortal>
<DialogOverlay />
<DialogContent :size="s">
<DialogHeader>
<DialogTitle>Size: {{ s }}</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="px-4 py-2 text-sm">
The {{ s }} size variant.
</div>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}

View File

@@ -1,13 +0,0 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emit = defineEmits<DialogRootEmits>()
</script>
<template>
<DialogRoot v-bind="props" @update:open="(open) => emit('update:open', open)">
<slot />
</DialogRoot>
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import { DialogClose } from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
</script>
<template>
<DialogClose as-child>
<slot>
<Button :aria-label="t('g.close')" size="icon" variant="muted-textonly">
<i class="icon-[lucide--x]" />
</Button>
</slot>
</DialogClose>
</template>

View File

@@ -1,33 +0,0 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import { DialogContent, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { DialogContentSize } from './dialog.variants'
import { dialogContentVariants } from './dialog.variants'
const {
size,
class: customClass = '',
...restProps
} = defineProps<
DialogContentProps & {
size?: DialogContentSize
class?: HTMLAttributes['class']
}
>()
const emits = defineEmits<DialogContentEmits>()
const forwarded = useForwardPropsEmits(restProps, emits)
</script>
<template>
<DialogContent
v-bind="forwarded"
:class="cn(dialogContentVariants({ size }), customClass)"
>
<slot />
</DialogContent>
</template>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import { DialogDescription } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '', ...delegated } = defineProps<
DialogDescriptionProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<DialogDescription
v-bind="delegated"
:class="cn('text-sm text-muted-foreground', customClass)"
>
<slot />
</DialogDescription>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '' } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex shrink-0 items-center justify-end gap-2 px-4 pt-2 pb-4',
customClass
)
"
>
<slot />
</div>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '' } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex shrink-0 items-center justify-between gap-2 px-4 pt-4 pb-2',
customClass
)
"
>
<slot />
</div>
</template>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import { DialogOverlay } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '', ...delegated } = defineProps<
DialogOverlayProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<DialogOverlay
v-bind="delegated"
:class="
cn(
'fixed inset-0 z-1700 bg-black/70 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
customClass
)
"
/>
</template>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import type { DialogPortalProps } from 'reka-ui'
import { DialogPortal } from 'reka-ui'
const props = defineProps<DialogPortalProps>()
</script>
<template>
<DialogPortal v-bind="props">
<slot />
</DialogPortal>
</template>

Some files were not shown because too many files have changed in this diff Show More