Compare commits

..

2 Commits

Author SHA1 Message Date
Jedrzej Kosinski
c7ccafc75b fix: surface failed manager install reasons in the progress toast
When an install is rejected (e.g. ComfyUI-Manager blocks it due to the
security_level/--listen restriction), the reason is captured in task
history but the progress toast only rendered the streamed server logs --
which stay empty for a request rejected before the task runs. The failed
task also misleadingly showed 'Completed'.

- Expose isTaskFailed and getTaskErrorMessages from the manager store
- Render a task's error messages in its failed panel
- Label failed tasks 'Failed' (in danger color) instead of 'Completed'

Amp-Thread-ID: https://ampcode.com/threads/T-019eafaf-ac38-734b-8fa1-1422ed378e78
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 22:30:45 -07:00
Jedrzej Kosinski
d349677767 fix: prevent infinite manager loading offline and surface backend security messages
- Wrap registry search in try/catch/finally so a failed/offline search clears the loading spinner instead of hanging forever, and expose error + retry
- Add request timeouts to the registry and manager axios clients so hung sockets reject
- Surface ComfyUI-Manager's actionable backend error message (e.g. required security_level/--listen) instead of a generic 403 fallback
- Show a retryable connection-error empty state in the manager dialog

Amp-Thread-ID: https://ampcode.com/threads/T-019eafaf-ac38-734b-8fa1-1422ed378e78
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 22:11:28 -07:00
267 changed files with 3826 additions and 13207 deletions

View File

@@ -29,5 +29,3 @@ runs:
if: ${{ inputs.include_build_step == 'true' }}
shell: bash
run: pnpm build
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'

View File

@@ -109,27 +109,3 @@ jobs:
exit 1
fi
echo '✅ No PostHog references found'
- name: Scan dist for Customer.io telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Customer.io references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'CustomerIoTelemetryProvider' \
-e '@customerio/cdp-analytics-browser' \
-e 'customerio-gist-web' \
-e '(?i)cdp\.customer\.io' \
-e 'Comfy\.CustomerIo' \
dist; then
echo '❌ ERROR: Customer.io references found in dist assets!'
echo 'Customer.io must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Customer.io references found'

View File

@@ -47,8 +47,6 @@ jobs:
- name: Build cloud frontend
run: pnpm build:cloud
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
- name: Upload cloud frontend
uses: actions/upload-artifact@v6

View File

@@ -65,7 +65,6 @@
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unreachable": "error",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
@@ -74,14 +73,12 @@
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"vitest/expect-expect": "off",
"vitest/no-conditional-expect": "off",
"vitest/no-disabled-tests": "off",
"vitest/no-standalone-expect": "off",
"vitest/valid-title": "off",
"vitest/require-to-throw-message": "off",
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"typescript/no-this-alias": "off",
"typescript/no-useless-default-assignment": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",

View File

@@ -15,9 +15,7 @@ test.describe('Download page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop — Run AI on Your Hardware'
)
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,23 +1,23 @@
{
"name": "Comfy",
"short_name": "Comfy",
"id": "/",
"start_url": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
}
],
"theme_color": "#211927",
"background_color": "#211927",
"display": "standalone",
"id": "/",
"start_url": "/"
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -21,7 +21,7 @@ export const Default: Story = {
args: {
title: 'Product',
links: [
{ label: 'Desktop', href: '/download' },
{ label: 'Local', href: '/local' },
{ label: 'Cloud', href: '/cloud' },
{ label: 'API', href: '/api' },
{ label: 'Enterprise', href: '/enterprise' }

View File

@@ -12,9 +12,9 @@ const meta: Meta<typeof ProductCard> = {
})
],
args: {
title: 'Comfy\nDesktop',
title: 'Comfy\nLocal',
description: 'Run ComfyUI on your own hardware.',
cta: 'SEE DESKTOP FEATURES',
cta: 'SEE LOCAL FEATURES',
href: '#',
bg: 'bg-primary-warm-gray'
}
@@ -31,9 +31,9 @@ export const AllCards: Story = {
template: `
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<ProductCard
title="Comfy\nDesktop"
title="Comfy\nLocal"
description="Run ComfyUI on your own hardware."
cta="SEE DESKTOP FEATURES"
cta="SEE LOCAL FEATURES"
href="#"
bg="bg-primary-warm-gray"
/>

View File

@@ -3,13 +3,11 @@ import type { Locale } from '../../../i18n/translations'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import type { Platform } from '../../../composables/useDownloadUrl'
import {
downloadUrls,
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import { captureDownloadClick } from '../../../scripts/posthog'
import BrandButton from '../../common/BrandButton.vue'
const { locale = 'en', class: customClass = '' } = defineProps<{
@@ -19,15 +17,13 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
const { downloadUrl, platform, showFallback } = useDownloadUrl()
const label = computed(() => t('download.hero.downloadLocal', locale))
const ICONS: Record<Platform, string> = {
const ICONS = {
windows: '/icons/os/windows.svg',
mac: '/icons/os/apple.svg'
}
} as const
interface ButtonSpec {
key: Platform
key: string
href: string
icon: string
ariaLabel?: string
@@ -44,18 +40,19 @@ const buttons = computed<ButtonSpec[]>(() => {
]
}
if (showFallback.value) {
const label = t('download.hero.downloadLocal', locale)
return [
{
key: 'windows',
href: downloadUrls.windows,
icon: ICONS.windows,
ariaLabel: `${label.value} — Windows`
ariaLabel: `${label} — Windows`
},
{
key: 'mac',
href: downloadUrls.macArm,
icon: ICONS.mac,
ariaLabel: `${label.value} — macOS`
ariaLabel: `${label} — macOS`
}
]
}
@@ -72,15 +69,17 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">
<img
:src="btn.icon"
alt=""
class="ppformula-text-center size-5 -translate-y-0.75"
aria-hidden="true"
/>
<span class="ppformula-text-center">{{ label }}</span>
<span class="ppformula-text-center">{{
t('download.hero.downloadLocal', locale)
}}</span>
</span>
</BrandButton>
</template>

View File

@@ -7,13 +7,13 @@ export const downloadUrls = {
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const
export type Platform = 'windows' | 'mac'
type DetectedPlatform = 'windows' | 'mac' | null
function isMobile(ua: string): boolean {
return /iphone|ipad|ipod|android/.test(ua)
}
function detectPlatform(ua: string): Platform | null {
function detectPlatform(ua: string): DetectedPlatform {
if (isMobile(ua)) return null
if (ua.includes('win')) return 'windows'
if (ua.includes('macintosh') || ua.includes('mac os x')) return 'mac'
@@ -23,7 +23,7 @@ function detectPlatform(ua: string): Platform | null {
// TODO: Only Windows x64 and macOS arm64 are available today.
// When Linux and/or macIntel builds are added, extend detection and URLs here.
export function useDownloadUrl() {
const platform = ref<Platform | null>(null)
const platform = ref<DetectedPlatform>(null)
const detected = ref(false)
const isMobileUa = ref(false)

View File

@@ -174,16 +174,16 @@ const translations = {
'zh-CN': '掌控每个模型、每个节点、每个步骤、每个输出。'
},
'products.local.title': {
en: 'Comfy\nDesktop',
'zh-CN': 'Comfy\n桌面版'
en: 'Comfy\nLocal',
'zh-CN': 'Comfy\n本地版'
},
'products.local.description': {
en: 'Run ComfyUI on your own hardware.',
'zh-CN': '在您自己的硬件上运行 ComfyUI。'
},
'products.local.cta': {
en: 'SEE DESKTOP FEATURES',
'zh-CN': '查看桌面版属性'
en: 'SEE LOCAL FEATURES',
'zh-CN': '查看本地版属性'
},
'products.cloud.title': {
en: 'Comfy\nCloud',
@@ -1057,18 +1057,18 @@ const translations = {
'zh-CN': 'Cloud 与本地运行 ComfyUI 有什么区别?'
},
'cloud.faq.2.a': {
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Comfy Desktop runs entirely on your computer, giving you full control and offline use.',
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Local runs entirely on your computer, giving you full control and offline use.',
'zh-CN':
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。Comfy 桌面版完全在您的电脑上运行,提供完全控制和离线使用。'
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。本地版完全在您的电脑上运行,提供完全控制和离线使用。'
},
'cloud.faq.3.q': {
en: 'Which version should I choose, Comfy Cloud or Comfy Desktop?',
'zh-CN': '我应该选择 Comfy Cloud 还是 Comfy 桌面版'
en: 'Which version should I choose, Comfy Cloud or local ComfyUI (self-hosted)?',
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI自托管'
},
'cloud.faq.3.a': {
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nComfy Desktop is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
'zh-CN':
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\nComfy 桌面版可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
},
'cloud.faq.4.q': {
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
@@ -1091,9 +1091,9 @@ const translations = {
'zh-CN': '我可以在 Comfy Cloud 上使用现有的工作流吗?'
},
'cloud.faq.6.a': {
en: 'Yes, your workflows work across Desktop and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
en: 'Yes, your workflows work across Local and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
'zh-CN':
'可以,您的工作流在桌面版和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
'可以,您的工作流在本地和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
},
'cloud.faq.7.q': {
en: 'Are all ComfyUI extensions and custom nodes supported?',
@@ -1145,9 +1145,9 @@ const translations = {
'zh-CN': '合作伙伴节点积分和我的 Cloud 订阅有什么区别?'
},
'cloud.faq.12.a': {
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Comfy Desktop. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Local/Self-Hosted ComfyUI. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
'zh-CN':
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和 Comfy 桌面版上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和本地/自托管 ComfyUI 上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
},
'cloud.faq.13.q': {
en: 'Can I cancel my subscription?',
@@ -1411,9 +1411,9 @@ const translations = {
'zh-CN': '合作伙伴节点'
},
'pricing.included.feature8.description': {
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and Comfy Desktop. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和 Comfy 桌面版间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
},
'pricing.included.feature9.title': {
en: 'Job queue',

View File

@@ -73,7 +73,7 @@ const websiteJsonLd = {
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" sizes="48x48" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#211927" />

View File

@@ -11,9 +11,9 @@ import { t } from '../i18n/translations'
---
<BaseLayout
title="Download Comfy Desktop — Run AI on Your Hardware"
title="Download Comfy — Run AI Locally"
description={t('download.hero.subtitle', 'en')}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
>
<CloudBannerSection />
<HeroSection client:load />

View File

@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
---
<BaseLayout
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
title="下载 Comfy"
description={t('download.hero.subtitle', 'zh-CN')}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>

View File

@@ -53,28 +53,3 @@ describe('initPostHog', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})
describe('captureDownloadClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the download event with the platform', async () => {
const { initPostHog, captureDownloadClick } = await import('./posthog')
initPostHog()
captureDownloadClick('mac')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:download_button_clicked',
{ platform: 'mac' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureDownloadClick } = await import('./posthog')
captureDownloadClick('windows')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -2,8 +2,6 @@ import posthog from 'posthog-js'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
import type { Platform } from '@/composables/useDownloadUrl'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
@@ -40,12 +38,3 @@ export function capturePageview() {
console.error('PostHog pageview capture failed', error)
}
}
export function captureDownloadClick(platform: Platform) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })
} catch (error) {
console.error('PostHog download click capture failed', error)
}
}

View File

@@ -1,60 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [100, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["cloud_importable_model.safetensors"]
},
{
"id": 2,
"type": "LoadImage",
"pos": [560, 100],
"size": [400, 314],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["cloud_unknown_model.safetensors", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "cloud_importable_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -1,48 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "TEST_MISSING_PACK_NODE_A",
"pos": [48, 86],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
},
{
"id": 2,
"type": "TEST_MISSING_PACK_NODE_B",
"pos": [520, 86],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -1,61 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "E2E_OldSampler",
"pos": [520, 100],
"size": [400, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [43, 20, 7, "euler", "normal"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -51,6 +51,20 @@ export class FeatureFlagHelper {
})
}
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
async setServerFlag(name: string, value: unknown): Promise<void> {
await this.setServerFlags({ [name]: value })
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -45,8 +45,6 @@ export const TestIds = {
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
missingNodePackExpand: 'missing-node-pack-expand',
missingNodePackCount: 'missing-node-pack-count',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',
@@ -60,19 +58,16 @@ export const TestIds = {
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
missingModelImport: 'missing-model-import',
missingModelImportableRows: 'missing-model-importable-rows',
missingModelLocate: 'missing-model-locate',
missingModelReferenceCount: 'missing-model-reference-count',
missingModelUnsupportedSection:
'missing-model-import-not-supported-section',
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-header-refresh',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
swapNodeGroupCount: 'swap-node-group-count',
missingMediaRow: 'missing-media-row',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',
@@ -139,8 +134,7 @@ export const TestIds = {
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red',
convertSubgraph: 'convert-to-subgraph-button',
bypass: 'bypass-button'
convertSubgraph: 'convert-to-subgraph-button'
},
menu: {
moreMenuContent: 'more-menu-content'

View File

@@ -1,135 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
* working user to /cloud/survey, while a genuine 404 (survey never submitted)
* must still route a not-completed user there. Drives a raw `page` so the cloud
* app boots against fully mocked endpoints (`comfyPage` would reach the OSS
* devtools backend during setup).
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function jsonRoute(body: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}
async function mockCloudBoot(page: Page) {
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Cloud user status (getUserCloudStatus) — an active account so the gate
// proceeds to the survey check instead of bouncing back to login.
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
// Genuine "not completed": the cloud backend returns 404 for a survey key that
// was never stored. This is the response that must still route to the survey.
async function mockSurveyNotCompleted(page: Page) {
await page.route('**/api/settings/onboarding_survey', (r) =>
r.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ code: 'NOT_FOUND', message: 'Setting not found' })
})
)
}
// Transient auth failure: a stale workspace token makes the authenticated
// survey check 401 — the hiccup that used to bounce working users.
async function mockSurveyTransient401(page: Page) {
await page.route('**/api/settings/onboarding_survey', (r) =>
r.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
code: 'UNAUTHORIZED',
message: 'User authentication required'
})
})
)
}
async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockSurveyTransient401(page)
await bootCloud(page)
await page.goto(APP_URL)
// The full app boots — CloudSurveyView is a standalone onboarding view, so
// reaching the extension manager proves we landed on the working app and
// the transient 401 was treated as "completed", not a bounce.
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/\/cloud\/survey/)
})
test('a not-completed (404) user landing on / is routed to the survey', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockSurveyNotCompleted(page)
await bootCloud(page)
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/survey/, { timeout: 45_000 })
})
})

View File

@@ -190,16 +190,6 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
'custom-color-palette-obsidian-dark.png'
)
})
test('Palette can modify @vue-nodes color', async ({ comfyPage }) => {
const node = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const getColor = () =>
node.body.evaluate((el) => getComputedStyle(el).backgroundColor)
const initialColor = await getColor()
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'solarized')
await expect.poll(getColor).not.toEqual(initialColor)
})
})
test.describe(

View File

@@ -48,36 +48,6 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
).toBeVisible()
})
test('Shows direct row label and locate action for a single replacement group', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const rowLabel = swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
await expect(rowLabel).toBeVisible()
await expect(
swapGroup.getByRole('button', {
name: 'Locate node on canvas',
exact: true
})
).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.swapNodeGroupCount)
).toHaveCount(0)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await rowLabel.click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Replace Node replaces a single group in-place', async ({
comfyPage
}) => {
@@ -131,14 +101,6 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
'normal',
1
])
if (mode.vueNodesEnabled) {
await expect(
comfyPage.vueNodes
.getWidgetByName('KSampler', 'denoise')
.locator('input')
).toHaveValue(/^1(?:\.0+)?$/)
}
})
test('Success toast is shown after replacement', async ({
@@ -154,55 +116,6 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
})
})
test.describe('Same-type replacement group', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_same_type'
)
})
test('Groups same-type replacement rows behind the title disclosure', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const countBadge = swapGroup.getByTestId(
TestIds.dialogs.swapNodeGroupCount
)
const childRows = swapGroup.getByRole('listitem')
const expandButton = swapGroup.getByRole('button', {
name: 'Expand E2E_OldSampler',
exact: true
})
await expect(expandButton).toBeVisible()
await expect(countBadge).toHaveText('2')
await expect(childRows).toHaveCount(0)
await expandButton.click()
await expect(childRows).toHaveCount(2)
await expect(
swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
).toHaveCount(2)
await swapGroup
.getByRole('button', {
name: 'Collapse E2E_OldSampler',
exact: true
})
.click()
await expect(childRows).toHaveCount(0)
})
})
test.describe('Multi-type replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(

View File

@@ -309,6 +309,50 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
})
test.describe('Empty graph defaults', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlag(
'node_library_essentials_enabled',
true
)
})
test('Defaults to Essentials when graph is empty', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await searchBoxV2.open()
const essentialsBtn = searchBoxV2.rootCategoryButton(
RootCategory.Essentials
)
await expect(essentialsBtn).toBeVisible()
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
})
test('Defaults to Most Relevant when graph has nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
await searchBoxV2.open()
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
await expect(
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
).toHaveAttribute('aria-pressed', 'false')
})
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -1,29 +1,16 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
AssetCreated,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { Asset } from '@comfyorg/ingest-types'
import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const WORKFLOW = 'missing/nested_subgraph_installed_model'
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
'models/checkpoints/cloud_importable_model.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
@@ -40,62 +27,13 @@ const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
}
}
const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
id: 'test-existing-cloud-importable-model',
name: 'asset-record-display-name.safetensors',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000204',
size: 2_048,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: CLOUD_IMPORTED_CANONICAL_MODEL_NAME
}
}
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
function getRequestedIncludeTags(requestUrl: string): string[] {
return (
new URL(requestUrl).searchParams
.get('include_tags')
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
function filterAssetsByRequest(
assets: ReadonlyArray<Asset>,
requestUrl: string
): Asset[] {
const includeTags = getRequestedIncludeTags(requestUrl)
return includeTags.length
? assets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: [...assets]
}
async function enableMissingModelImportFeatures(page: Page): Promise<void> {
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
model_upload_button_enabled: true,
private_models_enabled: true
}
})
}
test.describe(
'Errors tab - Cloud missing models',
{ tag: ['@cloud', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableMissingModelImportFeatures(comfyPage.page)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
@@ -150,216 +88,5 @@ test.describe(
await expect(errorsTab).toBeHidden()
})
test('separates importable cloud models from unsupported rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const importableRows = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelImportableRows
)
const unsupportedSection = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelUnsupportedSection
)
await expect(
importableRows.getByRole('button', {
name: CLOUD_IMPORTABLE_MODEL_NAME,
exact: true
})
).toBeVisible()
await expect(
importableRows.getByTestId(TestIds.dialogs.missingModelImport)
).toBeVisible()
await expect(unsupportedSection).toBeVisible()
await expect(
unsupportedSection.getByText('Import Not Supported')
).toBeVisible()
await expect(
unsupportedSection.getByText(
/Nodes that reference the models below do not support imported models/
)
).toBeVisible()
await expect(
unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME)
).toBeVisible()
await expect(
unsupportedSection.getByText('Unknown', { exact: true })
).toBeVisible()
await expect(
unsupportedSection.getByRole('button', {
name: 'Load Image',
exact: true
})
).toBeVisible()
await expect(
unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport)
).toHaveCount(0)
})
test('opens cloud import with missing-model replacement context', async ({
comfyPage
}) => {
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
const response: AssetMetadata = {
content_length: 1024,
final_url:
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors',
content_type: 'application/octet-stream',
filename: 'replacement.safetensors',
tags: ['loras']
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const urlInput = comfyPage.page.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await expect(urlInput).toBeVisible()
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors'
)
await comfyPage.page
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible()
await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible()
await expect(
uploadDialog.getByText(
/Locked to (Checkpoints|checkpoints) for this missing model/
)
).toBeVisible()
})
test('uses the synced asset filename when applying an already imported cloud model', async ({
comfyPage
}) => {
let isImportedAssetAvailable = false
const visibleAssets = () =>
isImportedAssetAvailable
? [LOTUS_DIFFUSION_MODEL, EXISTING_CLOUD_IMPORTABLE_MODEL]
: [LOTUS_DIFFUSION_MODEL]
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, (route) => {
const assets = filterAssetsByRequest(
visibleAssets(),
route.request().url()
)
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
const response: AssetMetadata = {
content_length: 2048,
final_url:
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors',
content_type: 'application/octet-stream',
filename: CLOUD_IMPORTABLE_MODEL_NAME,
tags: ['checkpoints']
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await comfyPage.page.route('**/assets/download', (route) => {
isImportedAssetAvailable = true
const response: AssetCreated = {
...EXISTING_CLOUD_IMPORTABLE_MODEL,
created_new: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
const urlInput = uploadDialog.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors'
)
await uploadDialog
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await uploadDialog
.locator('[data-attr="upload-model-step2-confirm-button"]')
.click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.getNodeById(1)
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
?.value
})
)
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
})
}
)

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -12,18 +11,6 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -47,14 +34,15 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
).toHaveText(/\S/)
})
test('Should display model name and metadata', async ({ comfyPage }) => {
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(modelsGroup)).toBeVisible()
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
})
test('Should expand model row to show referencing nodes', async ({
@@ -65,33 +53,32 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'missing/missing_models_with_nodes'
)
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
)
const expandButton = modelsGroup.getByTestId(
await expect(locateButton.first()).toBeHidden()
const expandButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelExpand
)
await expect(expandButton.first()).toBeVisible()
await expectReferenceBadge(modelsGroup, 2)
await expandButton.first().click()
await expect(
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(2)
await expect(locateButton.first()).toBeVisible()
})
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
test('Should copy model name to clipboard', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
await expect(copyButton.first()).toBeVisible()
await copyButton.first().dispatchEvent('click')
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('/api/devtools/')
expect(copiedText).toContain('fake_model.safetensors')
})
test.describe('OSS-specific', { tag: '@oss' }, () => {
@@ -100,9 +87,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
await expect(copyUrlButton.first()).toBeVisible()
})
@@ -115,7 +102,6 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelDownload
)
await expect(downloadButton.first()).toBeVisible()
await expect(downloadButton.first()).toHaveText('Download')
})
test('Should render Download all and Refresh actions for one downloadable model', async ({

View File

@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
@@ -12,39 +12,27 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
)
})
test('Should show missing node pack card with guidance', async ({
comfyPage
}) => {
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
})
test('Should show missing node packs group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should show unknown pack node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
await expect(
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
).toBeVisible()
})
test('Should show subgraph missing node rows by default', async ({
test('Should expand pack group to reveal node type names', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -55,72 +43,66 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByRole('button', {
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
})
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
})
test('Should locate missing node from the row label', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
await missingNodeCard
.getByRole('button', { name: /collapse/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeHidden()
})
test('Should toggle grouped pack nodes from chevron and title', async ({
test('Locate node button is visible for expanded pack nodes', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_same_pack'
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
const packTitle = missingNodeCard.getByRole('button', {
name: 'test-missing-node-pack'
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
const locateButton = missingNodeCard.getByRole('button', {
name: /locate/i
})
const expandButton = missingNodeCard.getByTestId(
TestIds.dialogs.missingNodePackExpand
)
const firstNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_A'
})
const secondNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_B'
})
await expect(packTitle).toBeVisible()
await expect(
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
).toHaveText('2')
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await expandButton.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await packTitle.click()
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await packTitle.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await expect(locateButton.first()).toBeVisible()
// TODO: Add navigation assertion once subgraph node ID deduplication
// timing is fixed. Currently, collectMissingNodes runs before
// configure(), so execution IDs use pre-remapped node IDs that don't
// match the runtime graph. See PR #9510 / #8762.
})
})

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -9,18 +8,6 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -143,9 +130,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
'missing/missing_models_from_node_properties'
)
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
await expect(copyUrlButton.first()).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
@@ -169,7 +156,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
@@ -179,7 +168,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await comfyPage.canvas.click()
await expectReferenceBadge(missingModelGroup, 2)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(2\)/
)
})
test('Pasting a bypassed node does not add a new error', async ({
@@ -261,17 +252,14 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expectReferenceBadge(missingModelGroup, 2)
await expect(missingModelGroup).toContainText(/\(2\)/)
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
await expect(missingModelGroup).toContainText(/\(1\)/)
await comfyPage.canvas.click()
await expectReferenceBadge(missingModelGroup, 2)
await expect(missingModelGroup).toContainText(/\(2\)/)
})
})
@@ -396,7 +384,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
@@ -435,54 +425,6 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
}
)
test(
'Refreshing a resolved promoted missing model clears the combo invalid state',
{ tag: ['@widget', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
await comfyPage.vueNodes.waitForNodes()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')
.getByRole('combobox', { name: 'ckpt_name', exact: true })
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
const objectInfoRoute = /\/object_info$/
try {
await comfyPage.page.route(objectInfoRoute, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
await comfyPage.page
.getByTestId(TestIds.dialogs.missingModelRefresh)
.click()
await expect(missingModelGroup).toBeHidden()
await expect(promotedModelCombo).toBeVisible()
await expect(promotedModelCombo).not.toHaveAttribute(
'aria-invalid',
'true'
)
} finally {
await comfyPage.page.unroute(objectInfoRoute)
}
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -129,18 +129,23 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// A group + a KSampler node
await comfyPage.workflow.loadWorkflow('groups/single_group')
const bypass = comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass)
// Select group + node should show bypass button
await comfyPage.canvas.focus()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(bypass).toBeVisible()
await comfyPage.keyboard.delete()
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeVisible()
// (Only empty group is selected) should hide bypass button
await comfyPage.keyboard.selectAll()
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(bypass).toBeHidden()
// Deselect node (Only group is selected) should hide bypass button
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeHidden()
})
test.describe('Color Picker', () => {

View File

@@ -15,10 +15,6 @@ import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
// internal `setup()`, so the page first-loads with mocks already in place.
// See cloud-asset-default.spec.ts for the same pattern.
//
// Use `waitForAssets()` not `waitForAssets(MIXED_JOBS.length)`: VirtualGrid can
// virtualize the 3D card out of the initial render (#11635). Filtering reads the
// full store, so the per-filter count assertions still cover the behavior.
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
@@ -117,7 +113,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
@@ -140,7 +136,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
@@ -157,7 +153,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('video')
@@ -171,7 +167,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('audio')
@@ -183,7 +179,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('3d')
@@ -197,7 +193,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
@@ -215,7 +211,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')

View File

@@ -1105,56 +1105,3 @@ test.describe('Assets sidebar - drag and drop', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
})
})
test('Insert as node', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
createMockJob({
id: 'job1',
preview_output: {
filename: `1.png`,
type: 'temp',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob({
id: 'job2',
preview_output: {
filename: `2.png`,
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob({
id: 'job2',
preview_output: {
filename: `3.png`,
type: 'input',
nodeId: '1',
mediaType: 'images'
}
})
])
const { assetsTab } = comfyPage.menu
await assetsTab.open()
await assetsTab.waitForAssets()
await expect(assetsTab.assetCards).toHaveCount(3)
for (const [index, expectedName] of [
[0, '1.png [temp]'],
[1, '2.png [output]'],
[2, '3.png']
] as const) {
await comfyPage.nodeOps.clearGraph()
await assetsTab.assetCards.nth(index).scrollIntoViewIfNeeded()
await assetsTab.assetCards.nth(index).click({ button: 'right' })
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
await comfyPage.contextMenu.primeVueMenu.getByText('Insert as node').click()
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
const nodes = await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
const fileWidget = await nodes[0].getWidget(0)
await expect.poll(() => fileWidget.getValue()).toBe(expectedName)
}
})

View File

@@ -3,8 +3,6 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
const CREATE_GROUP_HOTKEY = 'Control+g'
@@ -219,40 +217,4 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
)
}).toPass({ timeout: 5000 })
})
test('Bypassing a group bypasses contents', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('.')
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
const toggleBypass = () =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass).click()
const bypassCount = () =>
comfyPage.page.evaluate(
() => graph!.nodes.filter((node) => node.mode === 4).length
)
expect(await bypassCount()).toBe(0)
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
await expect.poll(groupCount, 'create group').toBe(1)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.select()
await toggleBypass()
await expect.poll(bypassCount, 'setup bypass of single node').toBe(1)
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.page.mouse.click(groupPos.x, groupPos.y)
await toggleBypass()
await expect.poll(bypassCount, 'all nodes are set to bypassed').toBe(7)
await toggleBypass()
await expect.poll(bypassCount, 'all nodes are unbypassed').toBe(0)
await comfyPage.page.keyboard.down('Shift')
await ksampler.select()
await comfyPage.page.keyboard.up('Shift')
await toggleBypass()
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -1,101 +0,0 @@
import { expect } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
// text-sm rows render a single 20px line; a wrapped name is 40px+.
const SINGLE_LINE_MAX_HEIGHT_PX = 28
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
workspaces: [
{
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
},
{
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
created_at: '2026-01-02T00:00:00Z',
joined_at: '2026-01-02T00:00:00Z',
role: 'member'
}
]
}
const mockTokenResponse: WorkspaceTokenResponse = {
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: {
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal'
},
role: 'owner',
permissions: []
}
const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRemoteConfig)
})
)
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListWorkspacesResponse)
})
})
await page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTokenResponse)
})
)
await use(page)
}
})
test.describe('Workspace switcher', { tag: '@cloud' }, () => {
test('renders a long team workspace name on a single line', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByText(PERSONAL_WORKSPACE_NAME).click()
const longName = page.getByText(LONG_WORKSPACE_NAME)
await expect(longName).toBeVisible()
const box = await longName.boundingBox()
expect(box).not.toBeNull()
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
})
})

5
global.d.ts vendored
View File

@@ -49,11 +49,6 @@ interface Window {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
customer_io?: {
write_key?: string
site_id?: string
user_id?: string
}
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
const joinedPaths = toJoinedRelativePaths(fileNames)
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --type-aware --no-error-on-unmatched-pattern --fix ${joinedPaths}`,
`pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.14",
"version": "1.46.11",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -23,7 +23,6 @@
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
@@ -67,7 +66,6 @@
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

View File

@@ -111,7 +111,6 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -591,15 +591,7 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

572
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,6 @@ catalog:
'@astrojs/sitemap': ^3.7.3
'@astrojs/vue': ^6.0.1
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@eslint/js': ^10.0.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
@@ -80,7 +79,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.2
eslint-plugin-oxlint: 1.69.0
eslint-plugin-oxlint: 1.59.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
@@ -102,9 +101,9 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
oxfmt: ^0.54.0
oxlint: ^1.69.0
oxlint-tsgolint: ^0.23.0
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,9 +1,9 @@
<svg width="520" height="520" viewBox="0 0 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_227_285" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="520" height="520">
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#F2FF59"/>
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#EEFF30"/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#211927"/>
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F2FF59"/>
<rect y="0.751831" width="520" height="520" fill="#172DD7"/>
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#F0FF41"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -2,8 +2,8 @@
"PreviewImage": 4314,
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImageAdvanced": 1763,
"SaveImage": 1762,
"SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,
@@ -14,7 +14,6 @@
"UpscaleModelLoader": 629,
"UNETLoader": 606,
"VAELoader": 604,
"PreviewAny": 528,
"ShowText|pysssss": 527.5526981023964,
"ImageUpscaleWithModel": 523,
"ControlNetApplyAdvanced": 513,
@@ -25,12 +24,10 @@
"VHS_LoadVideo": 440,
"ImpactSwitch": 349,
"Reroute": 348,
"ResizeImageMaskNode": 337,
"ResizeAndPadImage": 336,
"ImageResizeKJv2": 335,
"StringConcatenate": 326,
"Text Concatenate": 325.7030402103206,
"SaveVideo": 321,
"PreviewAny": 319,
"KSamplerAdvanced": 304,
"SDXLPromptStyler": 297.0913411304729,
"Note": 291,
@@ -55,7 +52,6 @@
"CLIPLoader": 202,
"GeminiNode": 202,
"KSampler (Efficient)": 194.01083622636423,
"RemoveBackground": 187,
"ImageRemoveBackground+": 186,
"IPAdapterModelLoader": 184,
"PrimitiveInt": 183,
@@ -63,9 +59,7 @@
"LoadVideo": 179,
"Text Concatenate (JPS)": 175.98154639522735,
"PrimitiveNode": 175,
"PrimitiveStringMultiline": 166,
"Text Multiline": 165,
"GetImageSize": 164,
"Text Multiline": 163.04749064680308,
"GetImageSize+": 163,
"ImageScaleToTotalPixels": 157,
"String Literal": 150.11343489837878,
@@ -74,14 +68,15 @@
"DownloadAndLoadFlorence2Model": 144,
"LoadImageOutput": 143,
"IPAdapterUnifiedLoader": 141,
"BatchImagesNode": 134,
"FluxGuidance": 133,
"ImageBatchMulti": 133,
"FluxGuidance": 132,
"ByteDanceSeedreamNode": 130,
"CR Text Input Switch": 128.16473423438606,
"IPAdapterAdvanced": 128,
"If ANY execute A else B": 127.77279315110049,
"GeminiImage2Node": 124,
"GetImageSize": 121,
"PrimitiveStringMultiline": 120,
"IPAdapter": 118,
"CreateVideo": 116,
"ConditioningZeroOut": 115,
@@ -107,7 +102,6 @@
"DepthAnythingPreprocessor": 100,
"CR Apply LoRA Stack": 96.02556540496816,
"Image Filter Adjustments": 95.24168323839699,
"ComfyMathExpression": 96,
"SimpleMath+": 95,
"GroundingDinoSAMSegment (segment anything)": 93.28197782196906,
"Image Overlay": 93.28197782196906,
@@ -153,6 +147,7 @@
"Image Resize": 63.494455492264656,
"Automatic CFG": 63.494455492264656,
"Canny": 63,
"StringConcatenate": 63,
"DepthAnything_V2": 61,
"ImageCrop+": 60,
"ModelSamplingSD3": 59,
@@ -204,7 +199,6 @@
"BNK_CLIPTextEncodeAdvanced": 45.857106744413365,
"CR SDXL Aspect Ratio": 45.46516566112778,
"LoadAudio": 45,
"ResolutionSelector": 45,
"smZ CLIPTextEncode": 44.68128349455661,
"Bus Node": 44.68128349455661,
"PreviewTextNode": 44.68128349455661,
@@ -395,6 +389,7 @@
"SD_4XUpscale_Conditioning": 21,
"UltimateSDUpscaleCustomSample": 21,
"StyleModelLoader": 21,
"ResizeAndPadImage": 21,
"Text Random Prompt": 20.77287741413597,
"INPAINT_VAEEncodeInpaintConditioning": 20.77287741413597,
"BrushNet": 20.77287741413597,

View File

@@ -87,14 +87,6 @@ vi.mock('@/scripts/app', () => ({
}
}))
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -118,9 +110,6 @@ function createWrapper({
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
},
rightSidePanel: {
togglePanel: 'Toggle properties panel'
}
}
}
@@ -277,19 +266,6 @@ describe('TopMenuSection', () => {
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('tracks right side panel opens', async () => {
const { user } = createWrapper()
await user.click(
screen.getByRole('button', { name: 'Toggle properties panel' })
)
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)

View File

@@ -78,7 +78,7 @@
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
@@ -148,7 +148,6 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
@@ -283,14 +282,6 @@ const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
function openRightSidePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
rightSidePanelStore.togglePanel()
}
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)

View File

@@ -222,8 +222,7 @@ watch(visible, async (newVisible) => {
*/
useEventListener(dragHandleRef, 'mousedown', () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'actionbar_run_handle_drag_start',
element_group: 'actionbar'
button_id: 'actionbar_run_handle_drag_start'
})
})

View File

@@ -131,8 +131,7 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected',
element_group: 'queue'
button_id: 'queue_mode_option_run_on_change_selected'
})
queueMode.value = 'change'
}
@@ -146,8 +145,7 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected',
element_group: 'queue'
button_id: 'queue_mode_option_run_instant_selected'
})
queueMode.value = 'instant-idle'
}
@@ -239,8 +237,7 @@ const queuePrompt = async (e: Event) => {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'queue'
button_id: 'queue_run_multiple_batches_submitted'
})
}

View File

@@ -88,8 +88,7 @@ const home = computed(() => ({
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected',
element_group: 'breadcrumb'
button_id: 'breadcrumb_subgraph_root_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -104,8 +103,7 @@ const items = computed(() => {
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected',
element_group: 'breadcrumb'
button_id: 'breadcrumb_subgraph_item_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -33,6 +33,7 @@ const {
items,
gridStyle,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200,
@@ -41,6 +42,7 @@ const {
items: (T & { key: string })[]
gridStyle: CSSProperties
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemHeight?: number
defaultItemWidth?: number
@@ -59,6 +61,7 @@ const itemWidth = ref(defaultItemWidth)
const container = ref<HTMLElement | null>(null)
const { width, height } = useElementSize(container)
const { y: scrollY } = useScroll(container, {
throttle: scrollThrottle,
eventListenerOptions: { passive: true }
})

View File

@@ -40,8 +40,7 @@ function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source,
element_group: 'workflow_actions'
button_id: source
})
}
}

View File

@@ -101,8 +101,7 @@ const reportOpen = ref(false)
*/
const showReport = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_show_report_clicked',
element_group: 'error_dialog'
button_id: 'error_dialog_show_report_clicked'
})
reportOpen.value = true
}

View File

@@ -25,8 +25,7 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked',
element_group: 'error_dialog'
button_id: 'error_dialog_find_existing_issues_clicked'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`

View File

@@ -218,8 +218,7 @@ onMounted(() => {
*/
const onMinimapToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_minimap_toggle_clicked',
element_group: 'graph_menu'
button_id: 'graph_menu_minimap_toggle_clicked'
})
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
}
@@ -229,8 +228,7 @@ const onMinimapToggleClick = () => {
*/
const onLinkVisibilityToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_hide_links_toggle_clicked',
element_group: 'graph_menu'
button_id: 'graph_menu_hide_links_toggle_clicked'
})
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
}

View File

@@ -101,7 +101,6 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const {
hasAnySelection,
hasGroupedNodesSelection,
hasMultipleSelection,
isSingleNode,
isSingleSubgraph,
@@ -119,10 +118,7 @@ const showSubgraphButtons = computed(() => isSingleSubgraph.value)
const showBypass = computed(
() =>
isSingleNode.value ||
isSingleSubgraph.value ||
hasMultipleSelection.value ||
hasGroupedNodesSelection.value
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
)
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
const showMaskEditor = computed(() => isSingleImageNode.value)

View File

@@ -65,8 +65,7 @@ describe('InfoButton', () => {
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
button_id: 'selection_toolbox_node_info_opened'
})
})

View File

@@ -24,8 +24,7 @@ const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
button_id: 'selection_toolbox_node_info_opened'
})
}
</script>

View File

@@ -23,11 +23,8 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
:source-format="sourceFormat"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
@@ -89,7 +86,7 @@
/>
<RecordingControls
v-if="canUseRecording && !isPreview"
v-if="!isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -120,18 +117,9 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
const props = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -142,11 +130,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = resolveNode(nodeId) ?? null
node.value = resolveNode(props.nodeId!) ?? null
})
}
@@ -167,7 +155,6 @@ const {
canExport,
materialModes,
hasSkeleton,
sourceFormat,
hasRecording,
recordingDuration,
animations,

View File

@@ -1,47 +0,0 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -1,21 +0,0 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,7 +52,6 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -82,7 +81,6 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -91,7 +89,6 @@
<ExportControls
v-if="showExportControls"
:source-format="sourceFormat"
@export-model="handleExportModel"
/>
@@ -132,20 +129,14 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false,
sourceFormat = null
hasSkeleton = false
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
sourceFormat?: string | null
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')

View File

@@ -59,7 +59,6 @@ function buildViewerStub() {
canUseGizmo: ref(true),
canUseLighting: ref(true),
canExport: ref(true),
sourceFormat: ref<string | null>(null),
materialModes: ref(['original', 'normal', 'wireframe']),
animations: ref<Array<{ name: string; index: number }>>([]),
playing: ref(false),

View File

@@ -82,10 +82,7 @@
</div>
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
<ExportControls
:source-format="viewer.sourceFormat.value"
@export-model="viewer.exportModel"
/>
<ExportControls @export-model="viewer.exportModel" />
</div>
</div>
</div>

View File

@@ -13,12 +13,9 @@ const i18n = createI18n({
}
})
function renderComponent(
onExportModel?: (format: string) => void,
sourceFormat: string | null = null
) {
function renderComponent(onExportModel?: (format: string) => void) {
const utils = render(ExportControls, {
props: { onExportModel, sourceFormat },
props: { onExportModel },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
@@ -66,23 +63,6 @@ describe('ExportControls', () => {
).not.toBeInTheDocument()
})
it('offers only the source format for direct-export files (e.g. ply)', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel, 'ply')
await user.click(screen.getByRole('button', { name: 'Export model' }))
expect(screen.getByRole('button', { name: 'PLY' })).toBeVisible()
for (const label of ['GLB', 'OBJ', 'STL', 'FBX']) {
expect(
screen.queryByRole('button', { name: label })
).not.toBeInTheDocument()
}
await user.click(screen.getByRole('button', { name: 'PLY' }))
expect(onExportModel).toHaveBeenCalledWith('ply')
})
it('hides the popup when a click happens outside the trigger', async () => {
const { user } = renderComponent()

View File

@@ -35,14 +35,9 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
const { sourceFormat = null } = defineProps<{
sourceFormat?: string | null
}>()
const emit = defineEmits<{
(e: 'exportModel', format: string): void
@@ -50,7 +45,12 @@ const emit = defineEmits<{
const showExportFormats = ref(false)
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
function toggleExportFormats() {
showExportFormats.value = !showExportFormats.value

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="showBackgroundImage && !hasBackgroundImage">
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="showBackgroundImage && hasBackgroundImage">
<div v-if="hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,16 +83,12 @@
</div>
<PopupSlider
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="showBackgroundImage && hasBackgroundImage">
<div v-if="hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -118,9 +114,8 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
const { hdriActive = false } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -72,12 +72,9 @@ const i18n = createI18n({
messages: { en: { load3d: { export: 'Export' } } }
})
function renderComponent(
onExportModel?: (format: string) => void,
sourceFormat: string | null = null
) {
function renderComponent(onExportModel?: (format: string) => void) {
const utils = render(ViewerExportControls, {
props: { onExportModel, sourceFormat },
props: { onExportModel },
global: { plugins: [i18n] }
})
return { ...utils, user: userEvent.setup() }
@@ -117,32 +114,4 @@ describe('ViewerExportControls', () => {
expect(onExportModel).toHaveBeenCalledWith('glb')
})
it('offers only the source format for direct-export files (e.g. spz)', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel, 'spz')
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(Array.from(select.options).map((o) => o.value)).toEqual(['spz'])
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(onExportModel).toHaveBeenCalledWith('spz')
})
it('repairs the selected format when sourceFormat switches to a direct-export type', async () => {
const onExportModel = vi.fn()
const { user, rerender } = renderComponent(onExportModel, null)
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.value).toBe('obj')
await rerender({ onExportModel, sourceFormat: 'ply' })
expect(Array.from(select.options).map((o) => o.value)).toEqual(['ply'])
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(onExportModel).toHaveBeenCalledWith('ply')
})
})

View File

@@ -26,7 +26,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
@@ -34,30 +34,20 @@ 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 { getExportFormatOptions } from '@/extensions/core/load3d/constants'
const { sourceFormat = null } = defineProps<{
sourceFormat?: string | null
}>()
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
const exportFormat = ref('obj')
watch(
exportFormats,
(formats) => {
if (!formats.some((fmt) => fmt.value === exportFormat.value)) {
exportFormat.value = formats[0]?.value ?? ''
}
},
{ immediate: true }
)
const exportModel = (format: string) => {
emit('exportModel', format)
}

View File

@@ -14,7 +14,6 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
@@ -107,10 +106,6 @@ const isSingleSubgraphNode = computed(() => {
})
function closePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_closed',
element_group: 'right_side_panel'
})
rightSidePanelStore.closePanel()
}

View File

@@ -71,11 +71,12 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
name: 'MissingPackGroupRow',
template: `<div class="pack-row" data-testid="pack-row"
:data-show-info-button="String(showInfoButton)"
:data-show-node-id-badge="String(showNodeIdBadge)"
>
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
</div>`,
props: ['group', 'showInfoButton'],
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
emits: ['locate-node', 'open-manager-info']
}
}))
@@ -121,6 +122,7 @@ function makePackGroups(count = 2): MissingPackGroup[] {
function renderCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
@@ -128,6 +130,7 @@ function renderCard(
const result = render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
@@ -166,10 +169,12 @@ describe('MissingNodeCard', () => {
it('passes props correctly to MissingPackGroupRow children', () => {
renderCard({
showInfoButton: true
showInfoButton: true,
showNodeIdBadge: true
})
const row = screen.getAllByTestId('pack-row')[0]
expect(row.getAttribute('data-show-info-button')).toBe('true')
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
})
})
@@ -251,6 +256,7 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onLocateNode
},
@@ -273,6 +279,7 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onOpenManagerInfo
},

View File

@@ -56,29 +56,27 @@
>
</template>
</i18n-t>
<div class="flex flex-col gap-1 overflow-hidden py-2">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
<div v-if="shouldShowManagerButtons" class="px-4">
<Button
v-if="hasInstalledPacksPendingRestart"
variant="secondary"
size="sm"
variant="primary"
:disabled="isRestarting"
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<i
v-else
aria-hidden="true"
@@ -107,8 +105,9 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
const { showInfoButton, missingPackGroups } = defineProps<{
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}>()

View File

@@ -61,16 +61,16 @@ const i18n = createI18n({
messages: {
en: {
g: {
install: 'Install',
loading: 'Loading',
search: 'Search'
loading: 'Loading'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
@@ -100,6 +100,7 @@ function renderRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
const user = userEvent.setup()
@@ -109,6 +110,7 @@ function renderRow(
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
onLocateNode,
onOpenManagerInfo,
...props
@@ -116,6 +118,7 @@ function renderRow(
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
@@ -153,22 +156,9 @@ describe('MissingPackGroupRow', () => {
expect(screen.getByText(/Loading/)).toBeInTheDocument()
})
it('does not render header locate while pack metadata is resolving', () => {
renderRow({
group: makeGroup({
isResolving: true,
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('renders node count', () => {
renderRow()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
})
it('renders count of 5 for 5 nodeTypes', () => {
@@ -181,29 +171,38 @@ describe('MissingPackGroupRow', () => {
}))
})
})
expect(screen.getByText('5')).toBeInTheDocument()
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
})
})
describe('Node Type List', () => {
it('hides multiple nodeTypes behind the expand control by default', () => {
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
})
it('shows unknown pack nodeTypes by default', () => {
renderRow({ group: makeGroup({ packId: null }) })
expect(
screen.getByRole('button', { name: 'Collapse' })
).toBeInTheDocument()
it('expands when chevron is clicked', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('renders all nodeTypes after expanding', async () => {
it('collapses when chevron is clicked again', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
})
describe('Node Type List', () => {
async function expand(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Expand' }))
}
it('renders all nodeTypes when expanded', async () => {
const { user } = renderRow({
group: makeGroup({
nodeTypes: [
@@ -213,87 +212,40 @@ describe('MissingPackGroupRow', () => {
]
})
})
await user.click(screen.getByRole('button', { name: 'Expand' }))
await expand(user)
expect(screen.getByText('NodeA')).toBeInTheDocument()
expect(screen.getByText('NodeB')).toBeInTheDocument()
expect(screen.getByText('NodeC')).toBeInTheDocument()
})
it('hides multiple nodeTypes again after collapsing', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { user } = renderRow({ showNodeIdBadge: true })
await expand(user)
expect(screen.getByText('#10')).toBeInTheDocument()
})
it('hides a single nodeType without an expand control', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
})
})
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Expand' })
).not.toBeInTheDocument()
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { user } = renderRow({ showNodeIdBadge: false })
await expand(user)
expect(screen.queryByText('#10')).not.toBeInTheDocument()
})
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(screen.getByRole('button', { name: 'my-pack' }))
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('moves locate to the header when there is one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('emits locateNode when expanded child Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
it('emits locateNode when Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
await expand(user)
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('emits locateNode when node label is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: 'MissingA' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', () => {
renderRow({
it('does not show Locate for nodeType without nodeId', async () => {
const { user } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(user)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
@@ -301,6 +253,7 @@ describe('MissingPackGroupRow', () => {
it('handles mixed nodeTypes with and without nodeId', async () => {
const { user } = renderRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
@@ -308,7 +261,7 @@ describe('MissingPackGroupRow', () => {
]
})
})
await user.click(screen.getByRole('button', { name: 'Expand' }))
await expand(user)
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
expect(
@@ -321,25 +274,21 @@ describe('MissingPackGroupRow', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
renderRow()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
renderRow({ group: makeGroup({ packId: null }) })
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
})
it('shows Search when packId exists but pack not in registry', () => {
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
renderRow()
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
})
it('shows "Installed" state when pack is installed', () => {
@@ -363,9 +312,7 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
renderRow()
expect(
screen.getByRole('button', { name: 'Install' })
).toBeInTheDocument()
expect(screen.getByText('Install node pack')).toBeInTheDocument()
})
it('calls installAllPacks when Install button is clicked', async () => {
@@ -373,7 +320,9 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Install' }))
await user.click(
screen.getByRole('button', { name: /Install node pack/ })
)
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
@@ -420,7 +369,7 @@ describe('MissingPackGroupRow', () => {
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
renderRow({ group: makeGroup({ nodeTypes: [] }) })
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
})
})
})

View File

@@ -1,221 +1,187 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<div class="mb-2 flex w-full flex-col">
<!-- Pack header row: pack name + info + chevron -->
<div class="flex h-8 w-full items-center">
<!-- Warning icon for unknown packs -->
<i
v-if="group.packId === null && !group.isResolving"
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
group.packId === null && !group.isResolving
? 'text-warning-background'
: 'text-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
</span>
</p>
<Button
v-if="hasMultipleNodeTypes"
data-testid="missing-node-pack-expand"
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="unset"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
:aria-expanded="expanded"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleExpand"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
<i
v-if="isUnknownPack"
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleNodeTypes && !group.isResolving"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
:aria-expanded="expanded"
@click="toggleExpand"
>
{{ packDisplayName }}
</button>
<button
v-else-if="primaryLocatableNodeType"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
@click="handleLocateNode(primaryLocatableNodeType)"
>
{{ packDisplayName }}
</button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-2"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal"
:class="
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{ packDisplayName }}
</span>
#{{ nodeType.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="showInfoButton && group.packId !== null"
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i class="icon-[lucide--info] size-4" />
<i class="icon-[lucide--locate] size-3" />
</Button>
<span
v-if="showNodeCount"
data-testid="missing-node-pack-count"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
>
{{ group.nodeTypes.length }}
</span>
</span>
</span>
<div v-if="showInstallAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:disabled="isPackInstalled || isInstalling"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<span class="text-foreground min-w-0 truncate">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: isPackInstalled
? t('rightSidePanel.missingNodePacks.installed')
: t('g.install')
}}
</span>
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
<div
v-if="
shouldShowManagerButtons &&
group.packId !== null &&
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
"
class="flex w-full items-start py-1"
>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<i
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
/>
<i
v-else
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: comfyManagerStore.isPackInstalled(group.packId)
? t('rightSidePanel.missingNodePacks.installed')
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
</span>
</Button>
</div>
<!-- Registry still loading: packId known but result not yet available -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
class="flex w-full items-start py-1"
>
<div
v-else-if="showLoadingAction"
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('g.loading') }}
</span>
</div>
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<span class="text-foreground min-w-0 truncate">
{{ t('g.search') }}
</span>
</Button>
</div>
<Button
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<ul
v-if="showNodeTypeList"
:class="
cn(
'm-0 list-none space-y-1 p-0',
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
)
<!-- Search in Manager: fetch done but pack not found in registry -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex w-full items-start py-1"
>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<li
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
:class="
cn(
packTextButtonClass,
'text-muted-foreground hover:text-base-foreground'
)
"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
</span>
<Button
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ref, computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
@@ -227,9 +193,10 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group, showInfoButton } = defineProps<{
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -238,10 +205,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const expandedOverride = ref<boolean | null>(null)
const packTextButtonClass =
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
@@ -256,73 +219,17 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
nodePack.value ? [nodePack.value] : []
)
const isUnknownPack = computed(
() => group.packId === null && !group.isResolving
)
const packDisplayName = computed(() => {
if (group.packId === null) {
return t('rightSidePanel.missingNodePacks.unknownPack')
}
return nodePack.value?.name ?? group.packId
})
const isPackInstalled = computed(
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
)
const showInstallAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
(nodePack.value !== null || isPackInstalled.value)
)
const showLoadingAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
isLoading.value
)
const showSearchAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
!showLoadingAction.value
)
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
const expanded = computed(
() =>
expandedOverride.value ??
(isUnknownPack.value && hasMultipleNodeTypes.value)
)
const showNodeTypeList = computed(
() =>
(isUnknownPack.value && group.nodeTypes.length === 1) ||
(hasMultipleNodeTypes.value && expanded.value)
)
const primaryLocatableNodeType = computed(() => {
if (group.isResolving) return null
if (isUnknownPack.value) return null
if (group.nodeTypes.length !== 1) return null
const [nodeType] = group.nodeTypes
return isLocatableNodeType(nodeType) ? nodeType : null
})
function handlePackInstallClick() {
if (!group.packId) return
if (!isPackInstalled.value) {
if (!comfyManagerStore.isPackInstalled(group.packId)) {
void installAllPacks()
}
}
const expanded = ref(false)
function toggleExpand() {
expandedOverride.value = !expanded.value
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
@@ -334,14 +241,10 @@ function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function isLocatableNodeType(
nodeType: MissingNodeType
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
return typeof nodeType !== 'string' && nodeType.nodeId != null
}
function handleLocateNode(nodeType: MissingNodeType) {
if (!isLocatableNodeType(nodeType)) return
emit('locateNode', String(nodeType.nodeId))
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locateNode', String(nodeType.nodeId))
}
}
</script>

View File

@@ -530,16 +530,14 @@ describe('TabErrors.vue', () => {
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'OldSampler' })
).toBeInTheDocument()
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
expect(screen.getByText('KSampler')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Replace Node/ })
).toBeInTheDocument()
})
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
@@ -557,8 +555,11 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()
expect(
screen.queryByTestId('missing-model-header-refresh')
).not.toBeInTheDocument()
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
})
})

View File

@@ -94,10 +94,9 @@
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
variant="secondary"
size="sm"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
@@ -113,6 +112,7 @@
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span
v-if="
@@ -148,6 +148,7 @@
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:show-node-id-badge="showNodeIdBadge"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
@@ -157,6 +158,7 @@
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
@@ -246,6 +248,7 @@
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateAssetNode"
/>
@@ -300,9 +303,11 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
@@ -316,6 +321,7 @@ import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCar
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -343,6 +349,7 @@ const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -366,6 +373,12 @@ function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
NodeBadgeMode.None
)
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
@@ -452,13 +465,20 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery)
const missingModelDownloadableModels = computed(() => {
if (isCloud) return []
return getDownloadableModels(missingModelGroups.value)
})
const showMissingModelHeaderRefresh = computed(
() => !isCloud && missingModelGroups.value.length > 0
() =>
!isCloud &&
missingModelGroups.value.length > 0 &&
missingModelDownloadableModels.value.length === 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}

View File

@@ -58,8 +58,7 @@ describe('useErrorActions', () => {
openGitHubIssues()
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_github_issues_clicked'
})
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
@@ -124,8 +123,7 @@ describe('useErrorActions', () => {
findOnGitHub('CUDA out of memory')
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_find_existing_issues_clicked'
})
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
expect(windowOpenSpy).toHaveBeenCalledWith(

View File

@@ -9,8 +9,7 @@ export function useErrorActions() {
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
@@ -26,8 +25,7 @@ export function useErrorActions() {
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(

View File

@@ -5,9 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
@@ -71,7 +74,8 @@ describe('NodeSearchBoxPopover', () => {
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] }
filters: { type: Array, default: () => [] },
defaultRootFilter: { type: String, default: null }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
@@ -79,7 +83,8 @@ describe('NodeSearchBoxPopover', () => {
emit('addNode', nodeDef, dragEvent)
return {}
},
template: '<div data-testid="search-content-v2"></div>'
template:
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
})
const pinia = createTestingPinia({
@@ -276,4 +281,75 @@ describe('NodeSearchBoxPopover', () => {
)
})
})
describe('defaultRootFilter on dialog open', () => {
function setGraphNodes(nodes: unknown[]) {
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes },
allow_searchbox: false,
setDirty: vi.fn(),
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
}
async function openSearch() {
useSearchBoxStore().visible = true
await nextTick()
}
it('defaults to Essentials when the graph is empty', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to Essentials when the canvas is not yet available', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to null when the graph has nodes', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
it('re-evaluates each time the dialog opens', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
useSearchBoxStore().visible = false
await nextTick()
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
})
})

View File

@@ -27,6 +27,7 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -77,6 +78,8 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
@@ -88,6 +91,7 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const canvasStore = useCanvasStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
@@ -103,6 +107,13 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const defaultRootFilter = ref<RootCategoryId | null>(null)
watch(visible, (isVisible) => {
if (!isVisible) return
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
? RootCategory.Essentials
: null
})
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -127,7 +138,6 @@ function clearFilters() {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')

View File

@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import {
createMockNodeDef,
setViewport,
@@ -230,6 +231,48 @@ describe('NodeSearchContent', () => {
})
})
it('should apply defaultRootFilter when provided and category is available', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
})
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
})
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
})
it('should show only API nodes when Partner Nodes filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

@@ -142,8 +142,9 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
[RootCategory.Custom]: isCustomNode
}
const { filters } = defineProps<{
const { filters, defaultRootFilter = null } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
defaultRootFilter?: RootCategoryId | null
}>()
const emit = defineEmits<{
@@ -194,8 +195,12 @@ function onSearchFocus() {
if (isMobile.value) isSidebarOpen.value = false
}
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilter = ref<RootCategoryId | null>(
defaultRootFilter === RootCategory.Essentials &&
!nodeAvailability.value.essential
? null
: defaultRootFilter
)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {

View File

@@ -150,8 +150,7 @@ const telemetry = useTelemetry()
function onLogoMenuClick(event: MouseEvent) {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_comfy_menu_opened',
element_group: 'sidebar'
button_id: 'sidebar_comfy_menu_opened'
})
menuRef.value?.toggle(event)
}
@@ -218,8 +217,7 @@ const extraMenuItems = computed(() => [
icon: 'icon-[lucide--settings]',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened',
element_group: 'sidebar'
button_id: 'sidebar_settings_menu_opened'
})
showSettings()
}
@@ -331,8 +329,7 @@ const handleNodes2ToggleClick = () => {
const onNodes2ToggleChange = async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
telemetry?.trackUiButtonClicked({
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`,
element_group: 'sidebar'
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
})
}
</script>

View File

@@ -138,23 +138,19 @@ const onTabClick = async (item: SidebarTabExtension) => {
if (isNodeLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_node_library_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_node_library_selected'
})
else if (isModelLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_model_library_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_model_library_selected'
})
else if (isWorkflowsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_workflows_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_workflows_selected'
})
else if (isAssetsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_assets_media_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_assets_media_selected'
})
await commandStore.commands

View File

@@ -21,8 +21,7 @@ const bottomPanelStore = useBottomPanelStore()
*/
const toggleConsole = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_bottom_panel_console_toggled',
element_group: 'sidebar'
button_id: 'sidebar_bottom_panel_console_toggled'
})
bottomPanelStore.toggleBottomPanel()
}

View File

@@ -30,8 +30,7 @@ const tooltipText = computed(
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
button_id: 'sidebar_settings_button_clicked'
})
}
</script>

View File

@@ -37,8 +37,7 @@ const tooltipText = computed(
*/
const toggleShortcutsPanel = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_shortcuts_panel_toggled',
element_group: 'sidebar'
button_id: 'sidebar_shortcuts_panel_toggled'
})
bottomPanelStore.togglePanel('shortcuts')
}

View File

@@ -29,8 +29,7 @@ const isSmall = computed(
*/
const openTemplates = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_templates_dialog_opened',
element_group: 'sidebar'
button_id: 'sidebar_templates_dialog_opened'
})
useWorkflowTemplateSelectorDialog().show('sidebar')
}

View File

@@ -118,8 +118,7 @@ const toggleBookmark = async () => {
const onHelpClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'node_library_help_button',
element_group: 'node_library'
button_id: 'node_library_help_button'
})
props.openNodeHelp(nodeDef.value)
}

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