Compare commits
46 Commits
fix/hero-a
...
version-bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3466ba08b | ||
|
|
5de30e6745 | ||
|
|
32ee82d414 | ||
|
|
f577f5f65b | ||
|
|
049befb699 | ||
|
|
06633fd90d | ||
|
|
edace7ac10 | ||
|
|
99e0bf7022 | ||
|
|
ba2abda023 | ||
|
|
c53f408ea2 | ||
|
|
348e0fbfee | ||
|
|
552f6e6325 | ||
|
|
73d6e8cb0a | ||
|
|
ba9feb801a | ||
|
|
83ee4bbe2d | ||
|
|
1357d3f212 | ||
|
|
1fe3e5c50c | ||
|
|
99aa4a4171 | ||
|
|
80eb676518 | ||
|
|
8b6b434387 | ||
|
|
e7acb2f688 | ||
|
|
c1c1fc6673 | ||
|
|
37bdf7a530 | ||
|
|
d577f2a0ea | ||
|
|
06e4e98b52 | ||
|
|
2b0bcda41f | ||
|
|
b9112f9bd7 | ||
|
|
b5b124fa9e | ||
|
|
e138d17459 | ||
|
|
f212c7d409 | ||
|
|
1d5801d6ef | ||
|
|
193f23e8c2 | ||
|
|
eaa6776559 | ||
|
|
afd42525fe | ||
|
|
0c392e53a2 | ||
|
|
46526cfabd | ||
|
|
fefbe7843c | ||
|
|
6445690ed3 | ||
|
|
603914e78f | ||
|
|
c7797b201e | ||
|
|
aa68573a6e | ||
|
|
79acf7be5e | ||
|
|
02adfd4b83 | ||
|
|
7c2c78b537 | ||
|
|
bd1fd0680e | ||
|
|
9617e498c9 |
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -29,3 +29,5 @@ runs:
|
||||
if: ${{ inputs.include_build_step == 'true' }}
|
||||
shell: bash
|
||||
run: pnpm build
|
||||
env:
|
||||
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
|
||||
|
||||
2
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -47,6 +47,8 @@ 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
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
],
|
||||
"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",
|
||||
@@ -73,12 +74,14 @@
|
||||
"import/namespace": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"jest/expect-expect": "off",
|
||||
"jest/no-conditional-expect": "off",
|
||||
"jest/no-disabled-tests": "off",
|
||||
"jest/no-standalone-expect": "off",
|
||||
"jest/valid-title": "off",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -15,7 +15,9 @@ test.describe('Download page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
|
||||
await expect(page).toHaveTitle(
|
||||
'Download Comfy Desktop — Run AI on Your Hardware'
|
||||
)
|
||||
})
|
||||
|
||||
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 56 KiB |
@@ -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"
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#211927",
|
||||
"background_color": "#211927",
|
||||
"display": "standalone"
|
||||
"display": "standalone",
|
||||
"id": "/",
|
||||
"start_url": "/"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 8.8 KiB |
@@ -21,7 +21,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
title: 'Product',
|
||||
links: [
|
||||
{ label: 'Local', href: '/local' },
|
||||
{ label: 'Desktop', href: '/download' },
|
||||
{ label: 'Cloud', href: '/cloud' },
|
||||
{ label: 'API', href: '/api' },
|
||||
{ label: 'Enterprise', href: '/enterprise' }
|
||||
|
||||
@@ -12,9 +12,9 @@ const meta: Meta<typeof ProductCard> = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
title: 'Comfy\nLocal',
|
||||
title: 'Comfy\nDesktop',
|
||||
description: 'Run ComfyUI on your own hardware.',
|
||||
cta: 'SEE LOCAL FEATURES',
|
||||
cta: 'SEE DESKTOP 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\nLocal"
|
||||
title="Comfy\nDesktop"
|
||||
description="Run ComfyUI on your own hardware."
|
||||
cta="SEE LOCAL FEATURES"
|
||||
cta="SEE DESKTOP FEATURES"
|
||||
href="#"
|
||||
bg="bg-primary-warm-gray"
|
||||
/>
|
||||
|
||||
@@ -25,19 +25,19 @@ const { loaded: logoLoaded } = useHeroLogo(logoContainer)
|
||||
v-show="!logoLoaded"
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
|
||||
alt="Comfy logo"
|
||||
class="w-full"
|
||||
class="w-3/5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 px-6 py-12 lg:px-16">
|
||||
<h1
|
||||
class="text-4xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-6xl"
|
||||
class="text-primary-comfy-canvas text-4xl font-light whitespace-pre-line lg:text-6xl"
|
||||
>
|
||||
{{ t('hero.title', locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="mt-8 max-w-lg text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
class="text-primary-comfy-canvas mt-8 max-w-lg text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -3,11 +3,13 @@ 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<{
|
||||
@@ -17,13 +19,15 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
|
||||
|
||||
const { downloadUrl, platform, showFallback } = useDownloadUrl()
|
||||
|
||||
const ICONS = {
|
||||
const label = computed(() => t('download.hero.downloadLocal', locale))
|
||||
|
||||
const ICONS: Record<Platform, string> = {
|
||||
windows: '/icons/os/windows.svg',
|
||||
mac: '/icons/os/apple.svg'
|
||||
} as const
|
||||
}
|
||||
|
||||
interface ButtonSpec {
|
||||
key: string
|
||||
key: Platform
|
||||
href: string
|
||||
icon: string
|
||||
ariaLabel?: string
|
||||
@@ -40,19 +44,18 @@ 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} — Windows`
|
||||
ariaLabel: `${label.value} — Windows`
|
||||
},
|
||||
{
|
||||
key: 'mac',
|
||||
href: downloadUrls.macArm,
|
||||
icon: ICONS.mac,
|
||||
ariaLabel: `${label} — macOS`
|
||||
ariaLabel: `${label.value} — macOS`
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -69,17 +72,15 @@ 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">{{
|
||||
t('download.hero.downloadLocal', locale)
|
||||
}}</span>
|
||||
<span class="ppformula-text-center">{{ label }}</span>
|
||||
</span>
|
||||
</BrandButton>
|
||||
</template>
|
||||
|
||||
@@ -7,13 +7,13 @@ export const downloadUrls = {
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
|
||||
type DetectedPlatform = 'windows' | 'mac' | null
|
||||
export type Platform = 'windows' | 'mac'
|
||||
|
||||
function isMobile(ua: string): boolean {
|
||||
return /iphone|ipad|ipod|android/.test(ua)
|
||||
}
|
||||
|
||||
function detectPlatform(ua: string): DetectedPlatform {
|
||||
function detectPlatform(ua: string): Platform | null {
|
||||
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): DetectedPlatform {
|
||||
// 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<DetectedPlatform>(null)
|
||||
const platform = ref<Platform | null>(null)
|
||||
const detected = ref(false)
|
||||
const isMobileUa = ref(false)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type * as THREE from 'three'
|
||||
import * as THREE from 'three'
|
||||
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
|
||||
|
||||
import { prefersReducedMotion } from './useReducedMotion'
|
||||
|
||||
@@ -43,12 +44,34 @@ function buildImageUrls(): string[] {
|
||||
})
|
||||
}
|
||||
|
||||
function yieldToMain(): Promise<void> {
|
||||
const sched = (
|
||||
window as unknown as { scheduler?: { yield?: () => Promise<void> } }
|
||||
).scheduler
|
||||
if (sched && typeof sched.yield === 'function') return sched.yield()
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
function parseShapes(): THREE.Shape[] {
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
})
|
||||
return shapes
|
||||
}
|
||||
|
||||
function loadTextures(urls: string[]): Promise<THREE.Texture[]> {
|
||||
return Promise.all(
|
||||
urls.map(
|
||||
(url) =>
|
||||
new Promise<THREE.Texture | null>((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const tex = new THREE.Texture(img)
|
||||
tex.needsUpdate = true
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
resolve(tex)
|
||||
}
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = url
|
||||
})
|
||||
)
|
||||
).then((results) => results.filter((t): t is THREE.Texture => t !== null))
|
||||
}
|
||||
|
||||
export function useHeroLogo(
|
||||
@@ -58,70 +81,12 @@ export function useHeroLogo(
|
||||
const cfg = { ...DEFAULTS, ...config }
|
||||
const loaded = ref(false)
|
||||
let cleanup: (() => void) | undefined
|
||||
let unmounted = false
|
||||
let idleHandle: number | undefined
|
||||
let timeoutHandle: number | undefined
|
||||
|
||||
const cancelScheduled = () => {
|
||||
if (
|
||||
idleHandle !== undefined &&
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.cancelIdleCallback === 'function'
|
||||
) {
|
||||
window.cancelIdleCallback(idleHandle)
|
||||
}
|
||||
idleHandle = undefined
|
||||
if (timeoutHandle !== undefined) {
|
||||
window.clearTimeout(timeoutHandle)
|
||||
timeoutHandle = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const setup = async () => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (unmounted) return
|
||||
const container = containerRef.value
|
||||
if (!container || prefersReducedMotion()) return
|
||||
|
||||
const [THREE, svgLoaderMod] = await Promise.all([
|
||||
import('three'),
|
||||
import('three/addons/loaders/SVGLoader.js')
|
||||
])
|
||||
if (unmounted) return
|
||||
|
||||
const parseShapes = (): THREE.Shape[] => {
|
||||
const { SVGLoader } = svgLoaderMod
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
})
|
||||
return shapes
|
||||
}
|
||||
|
||||
const loadTextures = (urls: string[]): Promise<THREE.Texture[]> => {
|
||||
return Promise.all(
|
||||
urls.map(
|
||||
(url) =>
|
||||
new Promise<THREE.Texture | null>((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const tex = new THREE.Texture(img)
|
||||
tex.needsUpdate = true
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
resolve(tex)
|
||||
}
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = url
|
||||
})
|
||||
)
|
||||
).then((results) =>
|
||||
results.filter((t): t is THREE.Texture => t !== null)
|
||||
)
|
||||
}
|
||||
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
@@ -160,9 +125,6 @@ export function useHeroLogo(
|
||||
)
|
||||
camera.position.z = cfg.zoom
|
||||
|
||||
await yieldToMain()
|
||||
if (disposed) return
|
||||
|
||||
// SVG shape
|
||||
const shapes = parseShapes()
|
||||
const tempGeo = new THREE.ShapeGeometry(shapes)
|
||||
@@ -173,15 +135,15 @@ export function useHeroLogo(
|
||||
const scaleFactor = 3 / (bb.max.y - bb.min.y)
|
||||
tempGeo.dispose()
|
||||
|
||||
await yieldToMain()
|
||||
if (disposed) return
|
||||
|
||||
// Image sequence textures — load first frame eagerly, rest lazily
|
||||
const urls = buildImageUrls()
|
||||
const textures = await loadTextures(urls.slice(0, 1))
|
||||
if (disposed) return
|
||||
|
||||
void loadTextures(urls.slice(1)).then((rest) => {
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
|
||||
loadTextures(urls.slice(1)).then((rest) => {
|
||||
if (!disposed) textures.push(...rest)
|
||||
})
|
||||
|
||||
@@ -205,9 +167,6 @@ export function useHeroLogo(
|
||||
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
|
||||
scene.add(bgPlane)
|
||||
|
||||
await yieldToMain()
|
||||
if (disposed) return
|
||||
|
||||
// Logo group
|
||||
const group = new THREE.Group()
|
||||
scene.add(group)
|
||||
@@ -230,9 +189,6 @@ export function useHeroLogo(
|
||||
logoMesh.renderOrder = 2
|
||||
group.add(logoMesh)
|
||||
|
||||
await yieldToMain()
|
||||
if (disposed) return
|
||||
|
||||
// Extrusion stencil mask
|
||||
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
|
||||
depth,
|
||||
@@ -256,9 +212,6 @@ export function useHeroLogo(
|
||||
extrudeMesh.renderOrder = 0
|
||||
group.add(extrudeMesh)
|
||||
|
||||
await yieldToMain()
|
||||
if (disposed) return
|
||||
|
||||
// Interaction
|
||||
let isDragging = false
|
||||
let previousX = 0
|
||||
@@ -308,7 +261,6 @@ export function useHeroLogo(
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
let firstFrameRendered = false
|
||||
|
||||
function animate() {
|
||||
if (disposed) return
|
||||
@@ -342,12 +294,6 @@ export function useHeroLogo(
|
||||
}
|
||||
|
||||
renderer.render(scene, camera)
|
||||
|
||||
if (!firstFrameRendered) {
|
||||
firstFrameRendered = true
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
@@ -372,29 +318,9 @@ export function useHeroLogo(
|
||||
console.error('[useHeroLogo] initialization failed:', err)
|
||||
cleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
idleHandle = window.requestIdleCallback(
|
||||
() => {
|
||||
idleHandle = undefined
|
||||
void setup()
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
} else {
|
||||
timeoutHandle = window.setTimeout(() => {
|
||||
timeoutHandle = undefined
|
||||
void setup()
|
||||
}, 200)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unmounted = true
|
||||
cancelScheduled()
|
||||
cleanup?.()
|
||||
})
|
||||
|
||||
|
||||
@@ -174,16 +174,16 @@ const translations = {
|
||||
'zh-CN': '掌控每个模型、每个节点、每个步骤、每个输出。'
|
||||
},
|
||||
'products.local.title': {
|
||||
en: 'Comfy\nLocal',
|
||||
'zh-CN': 'Comfy\n本地版'
|
||||
en: 'Comfy\nDesktop',
|
||||
'zh-CN': 'Comfy\n桌面版'
|
||||
},
|
||||
'products.local.description': {
|
||||
en: 'Run ComfyUI on your own hardware.',
|
||||
'zh-CN': '在您自己的硬件上运行 ComfyUI。'
|
||||
},
|
||||
'products.local.cta': {
|
||||
en: 'SEE LOCAL FEATURES',
|
||||
'zh-CN': '查看本地版属性'
|
||||
en: 'SEE DESKTOP 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. Local 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. Comfy Desktop runs entirely on your computer, giving you full control and offline use.',
|
||||
'zh-CN':
|
||||
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。本地版完全在您的电脑上运行,提供完全控制和离线使用。'
|
||||
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。Comfy 桌面版完全在您的电脑上运行,提供完全控制和离线使用。'
|
||||
},
|
||||
'cloud.faq.3.q': {
|
||||
en: 'Which version should I choose, Comfy Cloud or local ComfyUI (self-hosted)?',
|
||||
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI(自托管)?'
|
||||
en: 'Which version should I choose, Comfy Cloud or Comfy Desktop?',
|
||||
'zh-CN': '我应该选择 Comfy Cloud 还是 Comfy 桌面版?'
|
||||
},
|
||||
'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.\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.",
|
||||
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.",
|
||||
'zh-CN':
|
||||
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
|
||||
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\nComfy 桌面版可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 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 Local 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 Desktop 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 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.',
|
||||
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.',
|
||||
'zh-CN':
|
||||
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和本地/自托管 ComfyUI 上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流:Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
|
||||
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和 Comfy 桌面版上使用。每个节点有其自身的使用成本,由 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 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>.',
|
||||
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>.',
|
||||
'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 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<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 和 Comfy 桌面版间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
|
||||
@@ -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" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#211927" />
|
||||
|
||||
@@ -11,9 +11,9 @@ import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Download Comfy — Run AI Locally"
|
||||
title="Download Comfy Desktop — Run AI on Your Hardware"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
<HeroSection client:load />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="下载 — Comfy"
|
||||
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
|
||||
@@ -53,3 +53,28 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ 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'
|
||||
@@ -38,3 +40,12 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
61
browser_tests/assets/missing/node_replacement_same_type.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class VueNodeHelpers {
|
||||
* Matches against the actual title element, not the full node body.
|
||||
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
getNodeByTitle(title: string | RegExp): Locator {
|
||||
return this.page.locator('[data-node-id]').filter({
|
||||
has: this.page.getByTestId('node-title').filter({ hasText: title })
|
||||
})
|
||||
@@ -145,7 +145,7 @@ export class VueNodeHelpers {
|
||||
/**
|
||||
* Resolve the data-node-id of the first rendered node matching the title.
|
||||
*/
|
||||
async getNodeIdByTitle(title: string): Promise<string> {
|
||||
async getNodeIdByTitle(title: string | RegExp): Promise<string> {
|
||||
const node = this.getNodeByTitle(title).first()
|
||||
await node.waitFor({ state: 'visible' })
|
||||
|
||||
@@ -163,7 +163,7 @@ export class VueNodeHelpers {
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
async getFixtureByTitle(title: string | RegExp): Promise<VueNodeFixture> {
|
||||
const nodeId = await this.getNodeIdByTitle(title)
|
||||
return new VueNodeFixture(this.getNodeLocator(nodeId))
|
||||
}
|
||||
|
||||
@@ -51,20 +51,6 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -69,6 +69,24 @@ export class TemplateHelper {
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const customTemplatesHandler = async (route: Route) => {
|
||||
const customTemplates: Record<string, string[]> = {}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(customTemplates),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const customTemplatesPattern = '**/api/workflow_templates'
|
||||
this.routeHandlers.push({
|
||||
pattern: customTemplatesPattern,
|
||||
handler: customTemplatesHandler
|
||||
})
|
||||
await this.page.route(customTemplatesPattern, customTemplatesHandler)
|
||||
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
|
||||
@@ -60,16 +60,19 @@ 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',
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelReferenceCount: 'missing-model-reference-count',
|
||||
missingModelUnsupportedSection:
|
||||
'missing-model-import-not-supported-section',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingModelRefresh: 'missing-model-header-refresh',
|
||||
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',
|
||||
@@ -110,6 +113,10 @@ export const TestIds = {
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
assets: {
|
||||
browserModal: 'asset-browser-modal',
|
||||
card: 'asset-card'
|
||||
},
|
||||
subgraphEditor: {
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
iconEye: 'icon-eye',
|
||||
@@ -136,7 +143,8 @@ export const TestIds = {
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red',
|
||||
convertSubgraph: 'convert-to-subgraph-button'
|
||||
convertSubgraph: 'convert-to-subgraph-button',
|
||||
bypass: 'bypass-button'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
|
||||
45
browser_tests/fixtures/utils/flashDetector.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
function flagAttributeFor(testId: string) {
|
||||
const encoded = Array.from(testId, (ch) =>
|
||||
ch.charCodeAt(0).toString(16)
|
||||
).join('')
|
||||
return `data-flashed-${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags the first time an element matching `[data-testid="<testId>"]` is
|
||||
* present and rendered, sampled every frame via `requestAnimationFrame` from
|
||||
* page load. Catches a dialog that mounts and unmounts within a few frames,
|
||||
* which `toBeHidden()` (final state only) cannot.
|
||||
*
|
||||
* Must be called before navigation (e.g. before `comfyPage.setup()`).
|
||||
*/
|
||||
export async function trackElementFlash(
|
||||
page: Page,
|
||||
testId: string
|
||||
): Promise<{ hasFlashed: () => Promise<boolean> }> {
|
||||
const flagAttribute = flagAttributeFor(testId)
|
||||
|
||||
await page.addInitScript(
|
||||
({ id, attribute }: { id: string; attribute: string }) => {
|
||||
const sample = () => {
|
||||
const el = document.querySelector(`[data-testid="${CSS.escape(id)}"]`)
|
||||
if (el instanceof HTMLElement) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
document.documentElement.setAttribute(attribute, 'true')
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ id: testId, attribute: flagAttribute }
|
||||
)
|
||||
|
||||
return {
|
||||
hasFlashed: async () =>
|
||||
(await page.locator('html').getAttribute(flagAttribute)) === 'true'
|
||||
}
|
||||
}
|
||||
99
browser_tests/tests/cloud-asset-promoted-widget.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const WORKFLOW = 'missing/missing_model_promoted_widget'
|
||||
const HOST_NODE_ID = 2
|
||||
const WIDGET_NAME = 'ckpt_name'
|
||||
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
|
||||
|
||||
const test = createCloudAssetsFixture([STABLE_CHECKPOINT, STABLE_CHECKPOINT_2])
|
||||
|
||||
interface WidgetSnapshot {
|
||||
type: string
|
||||
value: string
|
||||
hasLayout: boolean
|
||||
}
|
||||
|
||||
async function getHostWidgetSnapshot(page: Page): Promise<WidgetSnapshot> {
|
||||
return await page.evaluate(
|
||||
({ nodeId, widgetName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((widget) => widget.name === widgetName)
|
||||
|
||||
return {
|
||||
type: widget?.type ?? '',
|
||||
value: String(widget?.value ?? ''),
|
||||
hasLayout: widget?.last_y != null
|
||||
}
|
||||
},
|
||||
{ nodeId: HOST_NODE_ID, widgetName: WIDGET_NAME }
|
||||
)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Promoted subgraph asset widgets',
|
||||
{ tag: ['@cloud', '@canvas', '@widget'] },
|
||||
() => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('legacy asset browser selection updates the promoted host widget value', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'checkpoints')
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => getHostWidgetSnapshot(comfyPage.page))
|
||||
.toMatchObject({
|
||||
type: 'asset',
|
||||
hasLayout: true
|
||||
})
|
||||
const initialWidget = await getHostWidgetSnapshot(comfyPage.page)
|
||||
expect(initialWidget.value).not.toBe(SELECTED_MODEL)
|
||||
|
||||
const hostNode = await comfyPage.nodeOps.getNodeRefById(HOST_NODE_ID)
|
||||
await hostNode.centerOnNode()
|
||||
const promotedWidget = await hostNode.getWidgetByName(WIDGET_NAME)
|
||||
await promotedWidget.click()
|
||||
|
||||
const modal = comfyPage.page.getByTestId(TestIds.assets.browserModal)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const assetCard = modal
|
||||
.getByTestId(TestIds.assets.card)
|
||||
.filter({ hasText: SELECTED_MODEL })
|
||||
.first()
|
||||
await expect(assetCard).toBeVisible()
|
||||
await assetCard.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
getHostWidgetSnapshot(comfyPage.page).then((widget) => widget.value)
|
||||
)
|
||||
.toBe(SELECTED_MODEL)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -190,6 +190,16 @@ 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(
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
@@ -35,56 +34,6 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId('properties-panel')
|
||||
}
|
||||
|
||||
async function setLocaleAndWaitForWorkflowReload(
|
||||
comfyPage: ComfyPage,
|
||||
locale: string
|
||||
) {
|
||||
await comfyPage.page.evaluate(async (targetLocale) => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error('No active workflow while waiting for locale reload')
|
||||
}
|
||||
|
||||
const changeTracker = workflow.changeTracker.constructor as unknown as {
|
||||
isLoadingGraph: boolean
|
||||
}
|
||||
|
||||
let sawLoading = false
|
||||
const waitForReload = new Promise<void>((resolve, reject) => {
|
||||
const timeoutAt = performance.now() + 5000
|
||||
|
||||
const tick = () => {
|
||||
if (changeTracker.isLoadingGraph) {
|
||||
sawLoading = true
|
||||
}
|
||||
|
||||
if (sawLoading && !changeTracker.isLoadingGraph) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
if (performance.now() > timeoutAt) {
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
tick()
|
||||
})
|
||||
|
||||
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
|
||||
await waitForReload
|
||||
}, locale)
|
||||
}
|
||||
|
||||
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
@@ -447,34 +396,33 @@ This is documentation for a custom node.
|
||||
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
test.describe('Locale-specific documentation', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
|
||||
これは日本語のドキュメントです。
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
|
||||
This is English documentation.
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Set locale to Japanese
|
||||
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
@@ -483,9 +431,7 @@ This is English documentation.
|
||||
const helpPage = await openSelectionToolboxHelp(comfyPage)
|
||||
await expect(helpPage).toContainText('KSamplerノード')
|
||||
await expect(helpPage).toContainText('これは日本語のドキュメントです')
|
||||
} finally {
|
||||
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Should handle network errors gracefully', async ({ comfyPage }) => {
|
||||
|
||||
@@ -48,6 +48,36 @@ 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
|
||||
}) => {
|
||||
@@ -101,6 +131,14 @@ 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 ({
|
||||
@@ -116,6 +154,55 @@ 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(
|
||||
|
||||
@@ -309,50 +309,6 @@ 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
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import type {
|
||||
Asset,
|
||||
AssetCreated,
|
||||
ListAssetsResponse
|
||||
} 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',
|
||||
@@ -27,13 +40,62 @@ 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
|
||||
@@ -88,5 +150,216 @@ 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)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -11,6 +12,18 @@ 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(
|
||||
@@ -34,15 +47,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should display model name with referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Should display model name and metadata', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
|
||||
await expect(getModelLabel(modelsGroup)).toBeVisible()
|
||||
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should expand model row to show referencing nodes', async ({
|
||||
@@ -53,32 +65,33 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelLocate
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(locateButton.first()).toBeHidden()
|
||||
|
||||
const expandButton = comfyPage.page.getByTestId(
|
||||
const expandButton = modelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelExpand
|
||||
)
|
||||
await expect(expandButton.first()).toBeVisible()
|
||||
await expectReferenceBadge(modelsGroup, 2)
|
||||
await expandButton.first().click()
|
||||
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
await expect(
|
||||
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Should copy model name to clipboard', async ({ comfyPage }) => {
|
||||
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const copyButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyName
|
||||
)
|
||||
const copyButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
await expect(copyButton.first()).toBeVisible()
|
||||
await copyButton.first().dispatchEvent('click')
|
||||
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toContain('fake_model.safetensors')
|
||||
expect(copiedText).toContain('/api/devtools/')
|
||||
})
|
||||
|
||||
test.describe('OSS-specific', { tag: '@oss' }, () => {
|
||||
@@ -87,9 +100,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
const copyUrlButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -102,6 +115,7 @@ 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 ({
|
||||
@@ -129,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
@@ -137,21 +151,11 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await Promise.all([objectInfoResponse, refreshButton.click()])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -8,6 +9,18 @@ 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')
|
||||
@@ -130,9 +143,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
const copyUrlButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
@@ -156,9 +169,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
@@ -168,9 +179,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(2\)/
|
||||
)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
@@ -252,14 +261,17 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(missingModelGroup).toContainText(/\(1\)/)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -384,9 +396,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
@@ -439,9 +449,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
|
||||
@@ -34,6 +34,22 @@ test.describe('Properties panel - Node selection', () => {
|
||||
await expect(panel.contentArea.getByText('seed')).toBeVisible()
|
||||
await expect(panel.contentArea.getByText('steps')).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'a linked widget is disabled',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
const seed = panel.contentArea.getByLabel('seed').locator('input')
|
||||
await comfyPage.searchBoxV2.addNode('Int')
|
||||
const intNode = await comfyPage.vueNodes.getFixtureByTitle(/Int/)
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await ksampler.select()
|
||||
await expect(seed).toBeEnabled()
|
||||
await intNode.getSlot('INT').dragTo(ksampler.getSlot('seed'))
|
||||
await expect(seed).toBeDisabled()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Multi-node', () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -129,23 +129,18 @@ 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.page.keyboard.press('Control+A')
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'.selection-toolbox *[data-testid="bypass-button"]'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
// 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()
|
||||
await expect(bypass).toBeVisible()
|
||||
await comfyPage.keyboard.delete()
|
||||
|
||||
// (Only empty group is selected) should hide bypass button
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(bypass).toBeHidden()
|
||||
})
|
||||
|
||||
test.describe('Color Picker', () => {
|
||||
|
||||
@@ -15,6 +15,10 @@ 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'])
|
||||
|
||||
@@ -113,7 +117,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
|
||||
@@ -136,7 +140,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
@@ -153,7 +157,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
@@ -167,7 +171,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('audio')
|
||||
@@ -179,7 +183,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(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('3d')
|
||||
@@ -193,7 +197,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
@@ -211,7 +215,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
@@ -1105,3 +1105,56 @@ 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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
|
||||
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, templateApiFixture)
|
||||
const test = mergeTests(createCloudAssetsFixture([]), templateApiFixture)
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorkflowTemplates } from '@/platform/workflow/templates/types/temp
|
||||
import { getWav } from '@e2e/fixtures/components/AudioPreview'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { trackElementFlash } from '@e2e/fixtures/utils/flashDetector'
|
||||
|
||||
async function checkTemplateFileExists(
|
||||
page: Page,
|
||||
@@ -505,3 +506,32 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
expect(popup.url()).toEqual(tutorialUrl)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Templates deeplink (new user)',
|
||||
{ tag: ['@slow', '@workflow'] },
|
||||
() => {
|
||||
test('templates dialog never flashes when first-time user opens a template link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templatesFlash = await trackElementFlash(
|
||||
comfyPage.page,
|
||||
TestIds.templates.content
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
||||
|
||||
await comfyPage.setup({
|
||||
clearStorage: true,
|
||||
url: '/?template=default'
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
expect(await templatesFlash.hasFlashed()).toBe(false)
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ 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'
|
||||
|
||||
@@ -175,6 +177,30 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('does not drag contents when control is held', async ({ comfyPage }) => {
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
|
||||
await expect.poll(groupCount, 'create group').toBe(1)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const initialNodeBounds = await ksampler.boundingBox()
|
||||
expect(initialNodeBounds).toBeTruthy()
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.move(groupPos.x, groupPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.move(groupPos.x + 100, groupPos.y)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await expect
|
||||
.poll(() => getGroupTitlePosition(comfyPage, 'Group'))
|
||||
.not.toEqual(groupPos)
|
||||
expect(await ksampler.boundingBox()).toEqual(initialNodeBounds)
|
||||
})
|
||||
|
||||
test('should keep groups aligned after loading legacy Vue workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -217,4 +243,40 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
|
||||
`pnpm exec oxlint --type-aware --no-error-on-unmatched-pattern --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.12",
|
||||
"version": "1.46.16",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -23,6 +23,7 @@
|
||||
"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",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
escapeVueI18nMessageSyntax,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
@@ -476,4 +477,49 @@ describe('formatUtil', () => {
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeVueI18nMessageSyntax', () => {
|
||||
it('escapes a literal @ that would break linked-message compilation', () => {
|
||||
expect(
|
||||
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
|
||||
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
|
||||
})
|
||||
|
||||
it('escapes @ in an email address', () => {
|
||||
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
|
||||
"support{'@'}comfy.org"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes interpolation braces', () => {
|
||||
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
|
||||
"size {'{'}w{'}'}x{'{'}h{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the plural pipe separator', () => {
|
||||
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
|
||||
"foreground {'|'} background"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the modulo percent so it cannot re-form %{', () => {
|
||||
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
|
||||
"50{'%'}{'{'}done{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes every occurrence in a single pass', () => {
|
||||
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
|
||||
"{'@'}a {'@'}b {'@'}c"
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves strings without syntax characters unchanged', () => {
|
||||
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
|
||||
'no special chars here'
|
||||
)
|
||||
expect(escapeVueI18nMessageSyntax('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,6 +178,40 @@ export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that vue-i18n's message compiler treats as syntax in message text,
|
||||
* so plain text has to escape them to render verbatim through `t()`/`st()`:
|
||||
*
|
||||
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
|
||||
* `Invalid linked format`.
|
||||
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
|
||||
* brace throws `Unterminated/Unbalanced closing brace`.
|
||||
* - `|` separates plural branches, so `a | b` silently renders as one branch.
|
||||
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
|
||||
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
|
||||
*
|
||||
* The set is a build-inlined `const enum` (`TokenChars`) in
|
||||
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
|
||||
*
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
|
||||
*/
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
|
||||
|
||||
/**
|
||||
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
|
||||
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
|
||||
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
|
||||
*
|
||||
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
|
||||
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
|
||||
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
|
||||
* escape output itself contains `{`/`}`, so it is not idempotent.
|
||||
*/
|
||||
export function escapeVueI18nMessageSyntax(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
|
||||
443
pnpm-lock.yaml
generated
@@ -211,8 +211,8 @@ catalogs:
|
||||
specifier: ^4.16.2
|
||||
version: 4.16.2
|
||||
eslint-plugin-oxlint:
|
||||
specifier: 1.59.0
|
||||
version: 1.59.0
|
||||
specifier: 1.69.0
|
||||
version: 1.69.0
|
||||
eslint-plugin-playwright:
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1
|
||||
@@ -277,14 +277,14 @@ catalogs:
|
||||
specifier: ^2.12.9
|
||||
version: 2.12.9
|
||||
oxfmt:
|
||||
specifier: ^0.44.0
|
||||
version: 0.44.0
|
||||
specifier: ^0.54.0
|
||||
version: 0.54.0
|
||||
oxlint:
|
||||
specifier: ^1.59.0
|
||||
version: 1.59.0
|
||||
specifier: ^1.69.0
|
||||
version: 1.69.0
|
||||
oxlint-tsgolint:
|
||||
specifier: ^0.20.0
|
||||
version: 0.20.0
|
||||
specifier: ^0.23.0
|
||||
version: 0.23.0
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -717,13 +717,13 @@ importers:
|
||||
version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1)))(eslint@10.4.0(jiti@2.6.1))
|
||||
eslint-plugin-better-tailwindcss:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3)
|
||||
version: 4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(tailwindcss@4.3.0)(typescript@5.9.3)
|
||||
eslint-plugin-import-x:
|
||||
specifier: 'catalog:'
|
||||
version: 4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))
|
||||
eslint-plugin-oxlint:
|
||||
specifier: 'catalog:'
|
||||
version: 1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0))
|
||||
version: 1.69.0(oxlint@1.69.0(oxlint-tsgolint@0.23.0))
|
||||
eslint-plugin-playwright:
|
||||
specifier: 'catalog:'
|
||||
version: 2.10.1(eslint@10.4.0(jiti@2.6.1))
|
||||
@@ -777,13 +777,13 @@ importers:
|
||||
version: 2.12.9
|
||||
oxfmt:
|
||||
specifier: 'catalog:'
|
||||
version: 0.44.0
|
||||
version: 0.54.0
|
||||
oxlint:
|
||||
specifier: 'catalog:'
|
||||
version: 1.59.0(oxlint-tsgolint@0.20.0)
|
||||
version: 1.69.0(oxlint-tsgolint@0.23.0)
|
||||
oxlint-tsgolint:
|
||||
specifier: 'catalog:'
|
||||
version: 0.20.0
|
||||
version: 0.23.0
|
||||
picocolors:
|
||||
specifier: 'catalog:'
|
||||
version: 1.1.1
|
||||
@@ -2751,276 +2751,276 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.44.0':
|
||||
resolution: {integrity: sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ==}
|
||||
'@oxfmt/binding-android-arm-eabi@0.54.0':
|
||||
resolution: {integrity: sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.44.0':
|
||||
resolution: {integrity: sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A==}
|
||||
'@oxfmt/binding-android-arm64@0.54.0':
|
||||
resolution: {integrity: sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.44.0':
|
||||
resolution: {integrity: sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg==}
|
||||
'@oxfmt/binding-darwin-arm64@0.54.0':
|
||||
resolution: {integrity: sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.44.0':
|
||||
resolution: {integrity: sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ==}
|
||||
'@oxfmt/binding-darwin-x64@0.54.0':
|
||||
resolution: {integrity: sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.44.0':
|
||||
resolution: {integrity: sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ==}
|
||||
'@oxfmt/binding-freebsd-x64@0.54.0':
|
||||
resolution: {integrity: sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.44.0':
|
||||
resolution: {integrity: sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg==}
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
|
||||
resolution: {integrity: sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.44.0':
|
||||
resolution: {integrity: sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw==}
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
|
||||
resolution: {integrity: sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.44.0':
|
||||
resolution: {integrity: sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw==}
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.44.0':
|
||||
resolution: {integrity: sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg==}
|
||||
'@oxfmt/binding-linux-arm64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
|
||||
resolution: {integrity: sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A==}
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
|
||||
resolution: {integrity: sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ==}
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
|
||||
resolution: {integrity: sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw==}
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
|
||||
resolution: {integrity: sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA==}
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.44.0':
|
||||
resolution: {integrity: sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA==}
|
||||
'@oxfmt/binding-linux-x64-gnu@0.54.0':
|
||||
resolution: {integrity: sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.44.0':
|
||||
resolution: {integrity: sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A==}
|
||||
'@oxfmt/binding-linux-x64-musl@0.54.0':
|
||||
resolution: {integrity: sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.44.0':
|
||||
resolution: {integrity: sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw==}
|
||||
'@oxfmt/binding-openharmony-arm64@0.54.0':
|
||||
resolution: {integrity: sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.44.0':
|
||||
resolution: {integrity: sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg==}
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
|
||||
resolution: {integrity: sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.44.0':
|
||||
resolution: {integrity: sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ==}
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
|
||||
resolution: {integrity: sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.44.0':
|
||||
resolution: {integrity: sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ==}
|
||||
'@oxfmt/binding-win32-x64-msvc@0.54.0':
|
||||
resolution: {integrity: sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.20.0':
|
||||
resolution: {integrity: sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA==}
|
||||
'@oxlint-tsgolint/darwin-arm64@0.23.0':
|
||||
resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.20.0':
|
||||
resolution: {integrity: sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==}
|
||||
'@oxlint-tsgolint/darwin-x64@0.23.0':
|
||||
resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.20.0':
|
||||
resolution: {integrity: sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==}
|
||||
'@oxlint-tsgolint/linux-arm64@0.23.0':
|
||||
resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.20.0':
|
||||
resolution: {integrity: sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==}
|
||||
'@oxlint-tsgolint/linux-x64@0.23.0':
|
||||
resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.20.0':
|
||||
resolution: {integrity: sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==}
|
||||
'@oxlint-tsgolint/win32-arm64@0.23.0':
|
||||
resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.20.0':
|
||||
resolution: {integrity: sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==}
|
||||
'@oxlint-tsgolint/win32-x64@0.23.0':
|
||||
resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.59.0':
|
||||
resolution: {integrity: sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg==}
|
||||
'@oxlint/binding-android-arm-eabi@1.69.0':
|
||||
resolution: {integrity: sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-android-arm64@1.59.0':
|
||||
resolution: {integrity: sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw==}
|
||||
'@oxlint/binding-android-arm64@1.69.0':
|
||||
resolution: {integrity: sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.59.0':
|
||||
resolution: {integrity: sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA==}
|
||||
'@oxlint/binding-darwin-arm64@1.69.0':
|
||||
resolution: {integrity: sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.59.0':
|
||||
resolution: {integrity: sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg==}
|
||||
'@oxlint/binding-darwin-x64@1.69.0':
|
||||
resolution: {integrity: sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.59.0':
|
||||
resolution: {integrity: sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug==}
|
||||
'@oxlint/binding-freebsd-x64@1.69.0':
|
||||
resolution: {integrity: sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.59.0':
|
||||
resolution: {integrity: sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw==}
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
|
||||
resolution: {integrity: sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.59.0':
|
||||
resolution: {integrity: sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q==}
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
|
||||
resolution: {integrity: sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.59.0':
|
||||
resolution: {integrity: sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg==}
|
||||
'@oxlint/binding-linux-arm64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.59.0':
|
||||
resolution: {integrity: sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA==}
|
||||
'@oxlint/binding-linux-arm64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.59.0':
|
||||
resolution: {integrity: sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q==}
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.59.0':
|
||||
resolution: {integrity: sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ==}
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.59.0':
|
||||
resolution: {integrity: sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ==}
|
||||
'@oxlint/binding-linux-riscv64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.59.0':
|
||||
resolution: {integrity: sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A==}
|
||||
'@oxlint/binding-linux-s390x-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.59.0':
|
||||
resolution: {integrity: sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w==}
|
||||
'@oxlint/binding-linux-x64-gnu@1.69.0':
|
||||
resolution: {integrity: sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.59.0':
|
||||
resolution: {integrity: sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA==}
|
||||
'@oxlint/binding-linux-x64-musl@1.69.0':
|
||||
resolution: {integrity: sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.59.0':
|
||||
resolution: {integrity: sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg==}
|
||||
'@oxlint/binding-openharmony-arm64@1.69.0':
|
||||
resolution: {integrity: sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.59.0':
|
||||
resolution: {integrity: sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA==}
|
||||
'@oxlint/binding-win32-arm64-msvc@1.69.0':
|
||||
resolution: {integrity: sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.59.0':
|
||||
resolution: {integrity: sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg==}
|
||||
'@oxlint/binding-win32-ia32-msvc@1.69.0':
|
||||
resolution: {integrity: sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.59.0':
|
||||
resolution: {integrity: sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA==}
|
||||
'@oxlint/binding-win32-x64-msvc@1.69.0':
|
||||
resolution: {integrity: sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -5395,10 +5395,10 @@ packages:
|
||||
eslint-import-resolver-node:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-oxlint@1.59.0:
|
||||
resolution: {integrity: sha512-g0DR+xSsnUdyaMc2KAXvBVGWz5V4GwlAE1PM+ocKxl2Eg7YgOjkRLLbxgJ3bhYOhRLhD8F0X4DjJu2FSDvrvAg==}
|
||||
eslint-plugin-oxlint@1.69.0:
|
||||
resolution: {integrity: sha512-ryJT8Pqb3jgWhmQcKA/D98K6UckthAR70wPTBI4rOjcaKJ9nmQkysTLbTVVEcdzfT9mznV/2MKspBsCCpXm36w==}
|
||||
peerDependencies:
|
||||
oxlint: ~1.59.0
|
||||
oxlint: ~1.69.0
|
||||
|
||||
eslint-plugin-playwright@2.10.1:
|
||||
resolution: {integrity: sha512-qea3UxBOb8fTwJ77FMApZKvRye5DOluDHcev0LDJwID3RELeun0JlqzrNIXAB/SXCyB/AesCW/6sZfcT9q3Edg==}
|
||||
@@ -7017,24 +7017,35 @@ packages:
|
||||
oxc-resolver@11.20.0:
|
||||
resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==}
|
||||
|
||||
oxfmt@0.44.0:
|
||||
resolution: {integrity: sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
oxlint-tsgolint@0.20.0:
|
||||
resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.59.0:
|
||||
resolution: {integrity: sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw==}
|
||||
oxfmt@0.54.0:
|
||||
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
oxlint-tsgolint: '>=0.18.0'
|
||||
svelte: ^5.0.0
|
||||
vite-plus: '*'
|
||||
peerDependenciesMeta:
|
||||
svelte:
|
||||
optional: true
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
oxlint-tsgolint@0.23.0:
|
||||
resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.69.0:
|
||||
resolution: {integrity: sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
oxlint-tsgolint: '>=0.22.1'
|
||||
vite-plus: '*'
|
||||
peerDependenciesMeta:
|
||||
oxlint-tsgolint:
|
||||
optional: true
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
@@ -8629,8 +8640,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.3:
|
||||
resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==}
|
||||
vue-component-type-helpers@3.3.4:
|
||||
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -10739,136 +10750,136 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.44.0':
|
||||
'@oxfmt/binding-android-arm-eabi@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.44.0':
|
||||
'@oxfmt/binding-android-arm64@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.44.0':
|
||||
'@oxfmt/binding-darwin-arm64@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.44.0':
|
||||
'@oxfmt/binding-darwin-x64@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.44.0':
|
||||
'@oxfmt/binding-freebsd-x64@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.44.0':
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.44.0':
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.44.0':
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.44.0':
|
||||
'@oxfmt/binding-linux-arm64-musl@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.44.0':
|
||||
'@oxfmt/binding-linux-x64-gnu@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.44.0':
|
||||
'@oxfmt/binding-linux-x64-musl@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.44.0':
|
||||
'@oxfmt/binding-openharmony-arm64@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.44.0':
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.44.0':
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.44.0':
|
||||
'@oxfmt/binding-win32-x64-msvc@0.54.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.20.0':
|
||||
'@oxlint-tsgolint/darwin-arm64@0.23.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.20.0':
|
||||
'@oxlint-tsgolint/darwin-x64@0.23.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.20.0':
|
||||
'@oxlint-tsgolint/linux-arm64@0.23.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.20.0':
|
||||
'@oxlint-tsgolint/linux-x64@0.23.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.20.0':
|
||||
'@oxlint-tsgolint/win32-arm64@0.23.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.20.0':
|
||||
'@oxlint-tsgolint/win32-x64@0.23.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.59.0':
|
||||
'@oxlint/binding-android-arm-eabi@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm64@1.59.0':
|
||||
'@oxlint/binding-android-arm64@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.59.0':
|
||||
'@oxlint/binding-darwin-arm64@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.59.0':
|
||||
'@oxlint/binding-darwin-x64@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.59.0':
|
||||
'@oxlint/binding-freebsd-x64@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.59.0':
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.59.0':
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.59.0':
|
||||
'@oxlint/binding-linux-arm64-gnu@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.59.0':
|
||||
'@oxlint/binding-linux-arm64-musl@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.59.0':
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.59.0':
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.59.0':
|
||||
'@oxlint/binding-linux-riscv64-musl@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.59.0':
|
||||
'@oxlint/binding-linux-s390x-gnu@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.59.0':
|
||||
'@oxlint/binding-linux-x64-gnu@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.59.0':
|
||||
'@oxlint/binding-linux-x64-musl@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.59.0':
|
||||
'@oxlint/binding-openharmony-arm64@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.59.0':
|
||||
'@oxlint/binding-win32-arm64-msvc@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.59.0':
|
||||
'@oxlint/binding-win32-ia32-msvc@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.59.0':
|
||||
'@oxlint/binding-win32-x64-msvc@1.69.0':
|
||||
optional: true
|
||||
|
||||
'@package-json/types@0.0.12': {}
|
||||
@@ -11312,7 +11323,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.3
|
||||
vue-component-type-helpers: 3.3.4
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -13448,7 +13459,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-better-tailwindcss@4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3):
|
||||
eslint-plugin-better-tailwindcss@4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(tailwindcss@4.3.0)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@eslint/css-tree': 3.6.9
|
||||
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
|
||||
@@ -13461,7 +13472,7 @@ snapshots:
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
eslint: 10.4.0(jiti@2.6.1)
|
||||
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
|
||||
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
@@ -13483,10 +13494,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-oxlint@1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0)):
|
||||
eslint-plugin-oxlint@1.69.0(oxlint@1.69.0(oxlint-tsgolint@0.23.0)):
|
||||
dependencies:
|
||||
jsonc-parser: 3.3.1
|
||||
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
|
||||
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
|
||||
|
||||
eslint-plugin-playwright@2.10.1(eslint@10.4.0(jiti@2.6.1)):
|
||||
dependencies:
|
||||
@@ -15453,61 +15464,61 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-arm64-msvc': 11.20.0
|
||||
'@oxc-resolver/binding-win32-x64-msvc': 11.20.0
|
||||
|
||||
oxfmt@0.44.0:
|
||||
oxfmt@0.54.0:
|
||||
dependencies:
|
||||
tinypool: 2.1.0
|
||||
optionalDependencies:
|
||||
'@oxfmt/binding-android-arm-eabi': 0.44.0
|
||||
'@oxfmt/binding-android-arm64': 0.44.0
|
||||
'@oxfmt/binding-darwin-arm64': 0.44.0
|
||||
'@oxfmt/binding-darwin-x64': 0.44.0
|
||||
'@oxfmt/binding-freebsd-x64': 0.44.0
|
||||
'@oxfmt/binding-linux-arm-gnueabihf': 0.44.0
|
||||
'@oxfmt/binding-linux-arm-musleabihf': 0.44.0
|
||||
'@oxfmt/binding-linux-arm64-gnu': 0.44.0
|
||||
'@oxfmt/binding-linux-arm64-musl': 0.44.0
|
||||
'@oxfmt/binding-linux-ppc64-gnu': 0.44.0
|
||||
'@oxfmt/binding-linux-riscv64-gnu': 0.44.0
|
||||
'@oxfmt/binding-linux-riscv64-musl': 0.44.0
|
||||
'@oxfmt/binding-linux-s390x-gnu': 0.44.0
|
||||
'@oxfmt/binding-linux-x64-gnu': 0.44.0
|
||||
'@oxfmt/binding-linux-x64-musl': 0.44.0
|
||||
'@oxfmt/binding-openharmony-arm64': 0.44.0
|
||||
'@oxfmt/binding-win32-arm64-msvc': 0.44.0
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.44.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.44.0
|
||||
'@oxfmt/binding-android-arm-eabi': 0.54.0
|
||||
'@oxfmt/binding-android-arm64': 0.54.0
|
||||
'@oxfmt/binding-darwin-arm64': 0.54.0
|
||||
'@oxfmt/binding-darwin-x64': 0.54.0
|
||||
'@oxfmt/binding-freebsd-x64': 0.54.0
|
||||
'@oxfmt/binding-linux-arm-gnueabihf': 0.54.0
|
||||
'@oxfmt/binding-linux-arm-musleabihf': 0.54.0
|
||||
'@oxfmt/binding-linux-arm64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-arm64-musl': 0.54.0
|
||||
'@oxfmt/binding-linux-ppc64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-riscv64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-riscv64-musl': 0.54.0
|
||||
'@oxfmt/binding-linux-s390x-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-x64-gnu': 0.54.0
|
||||
'@oxfmt/binding-linux-x64-musl': 0.54.0
|
||||
'@oxfmt/binding-openharmony-arm64': 0.54.0
|
||||
'@oxfmt/binding-win32-arm64-msvc': 0.54.0
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.54.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.54.0
|
||||
|
||||
oxlint-tsgolint@0.20.0:
|
||||
oxlint-tsgolint@0.23.0:
|
||||
optionalDependencies:
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.20.0
|
||||
'@oxlint-tsgolint/darwin-x64': 0.20.0
|
||||
'@oxlint-tsgolint/linux-arm64': 0.20.0
|
||||
'@oxlint-tsgolint/linux-x64': 0.20.0
|
||||
'@oxlint-tsgolint/win32-arm64': 0.20.0
|
||||
'@oxlint-tsgolint/win32-x64': 0.20.0
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.23.0
|
||||
'@oxlint-tsgolint/darwin-x64': 0.23.0
|
||||
'@oxlint-tsgolint/linux-arm64': 0.23.0
|
||||
'@oxlint-tsgolint/linux-x64': 0.23.0
|
||||
'@oxlint-tsgolint/win32-arm64': 0.23.0
|
||||
'@oxlint-tsgolint/win32-x64': 0.23.0
|
||||
|
||||
oxlint@1.59.0(oxlint-tsgolint@0.20.0):
|
||||
oxlint@1.69.0(oxlint-tsgolint@0.23.0):
|
||||
optionalDependencies:
|
||||
'@oxlint/binding-android-arm-eabi': 1.59.0
|
||||
'@oxlint/binding-android-arm64': 1.59.0
|
||||
'@oxlint/binding-darwin-arm64': 1.59.0
|
||||
'@oxlint/binding-darwin-x64': 1.59.0
|
||||
'@oxlint/binding-freebsd-x64': 1.59.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.59.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.59.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.59.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.59.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.59.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.59.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.59.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.59.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.59.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.59.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.59.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.59.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.59.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.59.0
|
||||
oxlint-tsgolint: 0.20.0
|
||||
'@oxlint/binding-android-arm-eabi': 1.69.0
|
||||
'@oxlint/binding-android-arm64': 1.69.0
|
||||
'@oxlint/binding-darwin-arm64': 1.69.0
|
||||
'@oxlint/binding-darwin-x64': 1.69.0
|
||||
'@oxlint/binding-freebsd-x64': 1.69.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.69.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.69.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.69.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.69.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.69.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.69.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.69.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.69.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.69.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.69.0
|
||||
oxlint-tsgolint: 0.23.0
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
@@ -17458,7 +17469,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.3: {}
|
||||
vue-component-type-helpers@3.3.4: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -79,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.59.0
|
||||
eslint-plugin-oxlint: 1.69.0
|
||||
eslint-plugin-playwright: ^2.10.1
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
@@ -101,9 +101,9 @@ catalog:
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
oxfmt: ^0.44.0
|
||||
oxlint: ^1.59.0
|
||||
oxlint-tsgolint: ^0.20.0
|
||||
oxfmt: ^0.54.0
|
||||
oxlint: ^1.69.0
|
||||
oxlint-tsgolint: ^0.23.0
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -3,7 +3,10 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
import {
|
||||
escapeVueI18nMessageSyntax,
|
||||
normalizeI18nKey
|
||||
} from '@/utils/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
@@ -44,8 +47,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
console.log(`Collected ${nodeDefs.length} node definitions`)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
@@ -60,7 +61,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
dataType
|
||||
escapeVueI18nMessageSyntax(dataType)
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
@@ -98,7 +99,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, value]) => [normalizeI18nKey(key), { name: value }])
|
||||
.map(([key, value]) => [
|
||||
normalizeI18nKey(key),
|
||||
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
|
||||
])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -121,7 +125,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name = input.name
|
||||
const name =
|
||||
input.name === undefined
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(input.name)
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
@@ -146,7 +153,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name = output.name in allDataTypesLocale ? undefined : output.name
|
||||
const name =
|
||||
output.name === undefined || output.name in allDataTypesLocale
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(output.name)
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
@@ -179,8 +189,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: nodeDef.display_name ?? nodeDef.name,
|
||||
description: nodeDef.description || undefined,
|
||||
display_name: escapeVueI18nMessageSyntax(
|
||||
nodeDef.display_name ?? nodeDef.name
|
||||
),
|
||||
description: nodeDef.description
|
||||
? escapeVueI18nMessageSyntax(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
@@ -192,7 +206,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [normalizeI18nKey(category), category])
|
||||
.map((category) => [
|
||||
normalizeI18nKey(category),
|
||||
escapeVueI18nMessageSyntax(category)
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -87,6 +87,14 @@ 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>
|
||||
@@ -110,6 +118,9 @@ function createWrapper({
|
||||
activeJobsShort: '{count} active | {count} active',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
},
|
||||
rightSidePanel: {
|
||||
togglePanel: 'Toggle properties panel'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +277,19 @@ 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)
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
@@ -148,6 +148,7 @@ 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'
|
||||
@@ -282,6 +283,14 @@ 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)
|
||||
|
||||
@@ -222,7 +222,8 @@ watch(visible, async (newVisible) => {
|
||||
*/
|
||||
useEventListener(dragHandleRef, 'mousedown', () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'actionbar_run_handle_drag_start'
|
||||
button_id: 'actionbar_run_handle_drag_start',
|
||||
element_group: 'actionbar'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -131,7 +131,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
|
||||
tooltip: t('menu.onChangeTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_on_change_selected'
|
||||
button_id: 'queue_mode_option_run_on_change_selected',
|
||||
element_group: 'queue'
|
||||
})
|
||||
queueMode.value = 'change'
|
||||
}
|
||||
@@ -145,7 +146,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
|
||||
tooltip: t('menu.instantTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_instant_selected'
|
||||
button_id: 'queue_mode_option_run_instant_selected',
|
||||
element_group: 'queue'
|
||||
})
|
||||
queueMode.value = 'instant-idle'
|
||||
}
|
||||
@@ -237,7 +239,8 @@ const queuePrompt = async (e: Event) => {
|
||||
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
button_id: 'queue_run_multiple_batches_submitted',
|
||||
element_group: 'queue'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
224
src/components/boundingBoxes/WidgetBoundingBoxes.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
|
||||
import boundingBoxes from '@/locales/en/main.json'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
boundingBoxes: boundingBoxes.boundingBoxes,
|
||||
palette: { swatchTitle: 'Edit', addColor: 'Add' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
x: 51,
|
||||
y: 51,
|
||||
width: 256,
|
||||
height: 256,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
|
||||
...over
|
||||
})
|
||||
|
||||
const fakeCtx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
fillText: () => {},
|
||||
drawImage: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
function prepCanvas(canvas: HTMLCanvasElement) {
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 100,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 100,
|
||||
configurable: true
|
||||
})
|
||||
canvas.getContext = (() =>
|
||||
fakeCtx) as unknown as HTMLCanvasElement['getContext']
|
||||
canvas.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
canvas.setPointerCapture = () => {}
|
||||
canvas.releasePointerCapture = () => {}
|
||||
}
|
||||
|
||||
function renderWidget(modelValue: BoundingBox[]) {
|
||||
const result = render(WidgetBoundingBoxes, {
|
||||
props: { nodeId: '1', modelValue },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
|
||||
prepCanvas(canvas)
|
||||
return { ...result, canvas }
|
||||
}
|
||||
|
||||
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
|
||||
const calls = emitted()['update:modelValue']
|
||||
return calls[calls.length - 1][0] as BoundingBox[]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('WidgetBoundingBoxes', () => {
|
||||
it('renders the canvas and editor shell', () => {
|
||||
renderWidget([])
|
||||
expect(
|
||||
screen.getByTestId('bounding-boxes').querySelector('canvas')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows the region editor panel when a region is active', () => {
|
||||
renderWidget([box()])
|
||||
expect(screen.getByText('obj')).toBeTruthy()
|
||||
expect(screen.getByText('text')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reveals the text field after switching the region to text', async () => {
|
||||
renderWidget([box()])
|
||||
expect(
|
||||
screen.queryByPlaceholderText('text to render (verbatim)')
|
||||
).toBeNull()
|
||||
await userEvent.click(screen.getByText('text'))
|
||||
expect(
|
||||
screen.getByPlaceholderText('text to render (verbatim)')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears all regions via the clear button', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await userEvent.click(screen.getByText('Clear all'))
|
||||
expect(lastBoxes(emitted)).toEqual([])
|
||||
})
|
||||
|
||||
it('draws a region through canvas pointer events', async () => {
|
||||
const { canvas, emitted } = renderWidget([])
|
||||
await fireEvent.pointerDown(canvas, {
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
pointerId: 1
|
||||
})
|
||||
await fireEvent.pointerMove(canvas, {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 1
|
||||
})
|
||||
await fireEvent.pointerUp(canvas, {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(lastBoxes(emitted)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('tracks focus and blur on the canvas', async () => {
|
||||
const { canvas } = renderWidget([box()])
|
||||
await fireEvent.focus(canvas)
|
||||
await fireEvent.blur(canvas)
|
||||
expect(canvas).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens an inline editor on double click', async () => {
|
||||
const { canvas, container } = renderWidget([box()])
|
||||
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
|
||||
expect(container.querySelector('textarea')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('syncs description edits back to the model', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText('description of this region'),
|
||||
'a caption'
|
||||
)
|
||||
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
|
||||
})
|
||||
|
||||
it('edits the text field once the region is a text region', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await userEvent.click(screen.getByText('text'))
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText('text to render (verbatim)'),
|
||||
'hello'
|
||||
)
|
||||
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
|
||||
})
|
||||
|
||||
it('deletes the active region with the Delete key', async () => {
|
||||
const { canvas, emitted } = renderWidget([box()])
|
||||
await fireEvent.keyDown(canvas, { key: 'Delete' })
|
||||
expect(lastBoxes(emitted)).toEqual([])
|
||||
})
|
||||
|
||||
it('clears hover state on pointer leave', async () => {
|
||||
const { canvas } = renderWidget([
|
||||
box({ x: 10, y: 10, width: 256, height: 256 })
|
||||
])
|
||||
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
|
||||
await fireEvent.pointerLeave(canvas)
|
||||
expect(canvas).toBeTruthy()
|
||||
})
|
||||
|
||||
it('commits the inline editor on blur', async () => {
|
||||
const { canvas, container, emitted } = renderWidget([box()])
|
||||
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
|
||||
const editor = container.querySelector('textarea')!
|
||||
await fireEvent.update(editor, 'committed')
|
||||
await fireEvent.blur(editor)
|
||||
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
|
||||
})
|
||||
})
|
||||
181
src/components/boundingBoxes/WidgetBoundingBoxes.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-expands flex size-full flex-col gap-1 select-none"
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeRegion"
|
||||
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
activeRegion.type === 'obj'
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="setActiveType('obj')"
|
||||
>
|
||||
{{ $t('boundingBoxes.typeObj') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
activeRegion.type === 'text'
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="setActiveType('text')"
|
||||
>
|
||||
{{ $t('boundingBoxes.typeText') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeRegion.type === 'text'"
|
||||
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('boundingBoxes.textLabel') }}
|
||||
</span>
|
||||
<Textarea
|
||||
v-model="activeRegion.text"
|
||||
:placeholder="$t('boundingBoxes.textPlaceholder')"
|
||||
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('boundingBoxes.descLabel') }}
|
||||
</span>
|
||||
<Textarea
|
||||
v-model="activeRegion.desc"
|
||||
:placeholder="$t('boundingBoxes.descPlaceholder')"
|
||||
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="shrink-0 truncate text-sm text-muted-foreground">
|
||||
{{ $t('boundingBoxes.colors') }}
|
||||
</span>
|
||||
<PaletteSwatchRow
|
||||
v-model="activeRegion.palette"
|
||||
:max="maxColors"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: string }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
|
||||
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
|
||||
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
|
||||
|
||||
const {
|
||||
canvasStyle,
|
||||
canvasCursor,
|
||||
focused,
|
||||
activeRegion,
|
||||
hasRegions,
|
||||
inlineEditor,
|
||||
maxColors,
|
||||
onPointerDown,
|
||||
onCanvasPointerMove,
|
||||
onDocPointerUp,
|
||||
onPointerLeave,
|
||||
onDoubleClick,
|
||||
onCanvasKeyDown,
|
||||
onInlineKeyDown,
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
inlineEditorEl,
|
||||
modelValue
|
||||
})
|
||||
</script>
|
||||
@@ -88,7 +88,8 @@ const home = computed(() => ({
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
button_id: 'breadcrumb_subgraph_root_selected',
|
||||
element_group: 'breadcrumb'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
@@ -103,7 +104,8 @@ const items = computed(() => {
|
||||
key: `subgraph-${subgraph.id}`,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_item_selected'
|
||||
button_id: 'breadcrumb_subgraph_item_selected',
|
||||
element_group: 'breadcrumb'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -33,7 +33,6 @@ const {
|
||||
items,
|
||||
gridStyle,
|
||||
bufferRows = 1,
|
||||
scrollThrottle = 64,
|
||||
resizeDebounce = 64,
|
||||
defaultItemHeight = 200,
|
||||
defaultItemWidth = 200,
|
||||
@@ -42,7 +41,6 @@ const {
|
||||
items: (T & { key: string })[]
|
||||
gridStyle: CSSProperties
|
||||
bufferRows?: number
|
||||
scrollThrottle?: number
|
||||
resizeDebounce?: number
|
||||
defaultItemHeight?: number
|
||||
defaultItemWidth?: number
|
||||
@@ -61,7 +59,6 @@ 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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source
|
||||
button_id: source,
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,8 @@ const reportOpen = ref(false)
|
||||
*/
|
||||
const showReport = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_show_report_clicked'
|
||||
button_id: 'error_dialog_show_report_clicked',
|
||||
element_group: 'error_dialog'
|
||||
})
|
||||
reportOpen.value = true
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_find_existing_issues_clicked'
|
||||
button_id: 'error_dialog_find_existing_issues_clicked',
|
||||
element_group: 'error_dialog'
|
||||
})
|
||||
const query = encodeURIComponent(queryString.value)
|
||||
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`
|
||||
|
||||
@@ -7,12 +7,26 @@ import { createI18n } from 'vue-i18n'
|
||||
import ErrorOverlay from './ErrorOverlay.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
const mockErrorGroups = vi.hoisted(() => ({
|
||||
allErrorGroups: { value: [] as ErrorGroup[] },
|
||||
missingPackGroups: { value: [] as MissingPackGroup[] },
|
||||
missingModelGroups: { value: [] as MissingModelGroup[] },
|
||||
missingMediaGroups: { value: [] as MissingMediaGroup[] },
|
||||
swapNodeGroups: { value: [] as SwapNodeGroup[] }
|
||||
}))
|
||||
|
||||
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
useErrorGroups: () => mockErrorGroups
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
@@ -62,7 +76,6 @@ function createTestI18n() {
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.',
|
||||
viewDetails: 'View details'
|
||||
@@ -108,6 +121,10 @@ function renderOverlay(props: { appMode?: boolean } = {}) {
|
||||
describe('ErrorOverlay', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
mockErrorGroups.missingPackGroups.value = []
|
||||
mockErrorGroups.missingModelGroups.value = []
|
||||
mockErrorGroups.missingMediaGroups.value = []
|
||||
mockErrorGroups.swapNodeGroups.value = []
|
||||
mockOpenPanel.mockClear()
|
||||
mockCanvasStore.linearMode = false
|
||||
mockCanvasStore.canvas = null
|
||||
@@ -116,17 +133,12 @@ describe('ErrorOverlay', () => {
|
||||
})
|
||||
|
||||
it('renders a single overlay message without list markup', async () => {
|
||||
renderOverlay()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -137,6 +149,12 @@ describe('ErrorOverlay', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
renderOverlay()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -145,21 +163,19 @@ describe('ErrorOverlay', () => {
|
||||
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
|
||||
'View details'
|
||||
)
|
||||
expect(screen.getByTestId('error-overlay-dismiss')).toHaveAccessibleName(
|
||||
'Close'
|
||||
)
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the app mode button label', async () => {
|
||||
renderOverlay({ appMode: true })
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -170,6 +186,12 @@ describe('ErrorOverlay', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
renderOverlay({ appMode: true })
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -7,47 +7,35 @@
|
||||
<div v-if="isVisible" class="pointer-events-none flex w-full justify-end">
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="error-overlay"
|
||||
class="pointer-events-auto flex w-fit max-w-120 min-w-80 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
class="pointer-events-auto relative flex w-fit max-w-120 min-w-80 flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 items-center gap-2 px-4">
|
||||
<span class="flex-1 text-sm font-bold text-destructive-background">
|
||||
<div class="flex w-full items-start gap-2 pr-8">
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-5 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 pb-3" data-testid="error-overlay-messages">
|
||||
<div
|
||||
class="flex w-full items-start gap-2 pr-8"
|
||||
data-testid="error-overlay-messages"
|
||||
>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
<p
|
||||
class="m-0 line-clamp-3 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-3">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
data-testid="error-overlay-dismiss"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<div class="flex w-full items-center justify-end pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
>
|
||||
@@ -58,6 +46,17 @@
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-2 right-2 size-6 rounded-sm"
|
||||
data-testid="error-overlay-dismiss"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-4 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -8,12 +8,26 @@ import { useErrorOverlayState } from './useErrorOverlayState'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
const mockErrorGroups = vi.hoisted(() => ({
|
||||
allErrorGroups: { value: [] as ErrorGroup[] },
|
||||
missingPackGroups: { value: [] as MissingPackGroup[] },
|
||||
missingModelGroups: { value: [] as MissingModelGroup[] },
|
||||
missingMediaGroups: { value: [] as MissingMediaGroup[] },
|
||||
swapNodeGroups: { value: [] as SwapNodeGroup[] }
|
||||
}))
|
||||
|
||||
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
useErrorGroups: () => mockErrorGroups
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
@@ -44,7 +58,6 @@ function createTestI18n() {
|
||||
messages: {
|
||||
en: {
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.'
|
||||
}
|
||||
@@ -92,20 +105,19 @@ function mountOverlayState() {
|
||||
describe('useErrorOverlayState', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
mockErrorGroups.missingPackGroups.value = []
|
||||
mockErrorGroups.missingModelGroups.value = []
|
||||
mockErrorGroups.missingMediaGroups.value = []
|
||||
mockErrorGroups.swapNodeGroups.value = []
|
||||
})
|
||||
|
||||
it('uses the raw message for a single uncataloged execution error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -116,6 +128,12 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -125,17 +143,12 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses toast copy for a single validation error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Required input is missing'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Required input is missing',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -152,6 +165,12 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Required input is missing'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -164,17 +183,12 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses display copy before raw copy when toast copy is absent', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Raw validation error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Friendly validation title',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -190,6 +204,12 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Raw validation error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -202,24 +222,12 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses toast copy for a single runtime error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'prompt',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
traceback: [],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Generation failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -237,6 +245,19 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'prompt',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
traceback: [],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -247,6 +268,44 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses group toast copy for a single missing media error', async () => {
|
||||
mockErrorGroups.missingMediaGroups.value = [
|
||||
{
|
||||
mediaType: 'image',
|
||||
items: [
|
||||
{
|
||||
name: 'image.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'image.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_media',
|
||||
groupKey: 'missing_media',
|
||||
displayTitle: 'Media input missing',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.',
|
||||
count: 1,
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -261,17 +320,6 @@ describe('useErrorOverlayState', () => {
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_media',
|
||||
groupKey: 'missing_media',
|
||||
displayTitle: 'Media input missing',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.',
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -281,6 +329,147 @@ describe('useErrorOverlayState', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses group copy for one missing model referenced by multiple nodes', async () => {
|
||||
mockErrorGroups.missingModelGroups.value = [
|
||||
{
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
models: [
|
||||
{
|
||||
name: 'missing.safetensors',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'missing.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [
|
||||
{ nodeId: '1', widgetName: 'ckpt_name' },
|
||||
{ nodeId: '2', widgetName: 'ckpt_name' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_model',
|
||||
groupKey: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: 'Model missing',
|
||||
toastMessage: 'CheckpointLoaderSimple is missing missing.safetensors.',
|
||||
count: 1,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Missing Models')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Import a model, or open the node to replace it.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses group copy for one execution group with multiple errors', async () => {
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:required_input_missing',
|
||||
displayTitle: 'Missing connection',
|
||||
displayMessage: 'Required input slots have no connection feeding them.',
|
||||
count: 2,
|
||||
priority: 1,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [
|
||||
{ message: 'KSampler is missing model' },
|
||||
{ message: 'KSampler is missing positive' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Missing connection')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Required input slots have no connection feeding them.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses aggregate copy for one missing model group with multiple rows', async () => {
|
||||
mockErrorGroups.missingModelGroups.value = [
|
||||
{
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
models: [
|
||||
{
|
||||
name: 'first.safetensors',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'first.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '1', widgetName: 'ckpt_name' }]
|
||||
},
|
||||
{
|
||||
name: 'second.safetensors',
|
||||
representative: {
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'second.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '2', widgetName: 'ckpt_name' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_model',
|
||||
groupKey: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: 'Missing models',
|
||||
toastMessage: '2 model files are missing.',
|
||||
count: 2,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('2 errors found')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Resolve them before running the workflow.'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show when a raw error has no resolved overlay message', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
@@ -295,26 +484,14 @@ describe('useErrorOverlayState', () => {
|
||||
expect(screen.getByTestId('message')).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('uses aggregate copy for multiple errors', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError([
|
||||
'First error',
|
||||
'Second error',
|
||||
'Third error',
|
||||
'Fourth error',
|
||||
'Fifth error',
|
||||
'Sixth error',
|
||||
'Seventh error'
|
||||
])
|
||||
}
|
||||
it('uses grouped error counts for aggregate copy', async () => {
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
displayMessage: 'First group message',
|
||||
count: 2,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -323,13 +500,31 @@ describe('useErrorOverlayState', () => {
|
||||
errors: [{ message: 'First error' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:CLIPTextEncode',
|
||||
displayTitle: 'Invalid CLIP input',
|
||||
displayMessage: 'Second group message',
|
||||
count: 3,
|
||||
priority: 1,
|
||||
cards: [
|
||||
{
|
||||
id: '2',
|
||||
title: 'CLIPTextEncode',
|
||||
errors: [{ message: 'Second error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('visible')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('7 errors found')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('5 errors found')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Resolve them before running the workflow.'
|
||||
)
|
||||
|
||||
@@ -4,11 +4,17 @@ import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
function resolveSingleOverlayCopy(
|
||||
group: ErrorGroup
|
||||
): { title?: string; message: string } | undefined {
|
||||
type OverlayCopy = { title?: string; message: string }
|
||||
|
||||
function resolveSingleOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
|
||||
if (group.type === 'execution') {
|
||||
const [card] = group.cards
|
||||
const [error] = card?.errors ?? []
|
||||
@@ -37,27 +43,119 @@ function resolveSingleOverlayCopy(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGroupOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
|
||||
const message =
|
||||
group.displayMessage ?? group.toastMessage ?? group.displayTitle
|
||||
if (!message) return undefined
|
||||
|
||||
return {
|
||||
title: group.displayTitle,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
function countMissingNodeReferences(groups: MissingPackGroup[]): number {
|
||||
return groups.reduce((count, group) => count + group.nodeTypes.length, 0)
|
||||
}
|
||||
|
||||
function countSwapNodeReferences(groups: SwapNodeGroup[]): number {
|
||||
return groups.reduce((count, group) => count + group.nodeTypes.length, 0)
|
||||
}
|
||||
|
||||
function getMissingModelRows(groups: MissingModelGroup[]) {
|
||||
return groups.flatMap((group) => group.models)
|
||||
}
|
||||
|
||||
function getMissingMediaRows(groups: MissingMediaGroup[]) {
|
||||
return groups.flatMap((group) => group.items)
|
||||
}
|
||||
|
||||
function hasSingleRowWithAtMostOneReference(
|
||||
rows: Array<{ referencingNodes: readonly unknown[] }>
|
||||
): boolean {
|
||||
const row = rows[0]
|
||||
return (
|
||||
rows.length === 1 && row !== undefined && row.referencingNodes.length <= 1
|
||||
)
|
||||
}
|
||||
|
||||
interface OverlayGroupContext {
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
missingMediaGroups: MissingMediaGroup[]
|
||||
swapNodeGroups: SwapNodeGroup[]
|
||||
}
|
||||
|
||||
function isSingleLeafGroup(
|
||||
group: ErrorGroup,
|
||||
context: OverlayGroupContext
|
||||
): boolean {
|
||||
if (group.type === 'execution') {
|
||||
return group.cards.length === 1 && group.cards[0]?.errors.length === 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_node') {
|
||||
return (
|
||||
context.missingPackGroups.length === 1 &&
|
||||
countMissingNodeReferences(context.missingPackGroups) === 1
|
||||
)
|
||||
}
|
||||
|
||||
if (group.type === 'swap_nodes') {
|
||||
return (
|
||||
context.swapNodeGroups.length === 1 &&
|
||||
countSwapNodeReferences(context.swapNodeGroups) === 1
|
||||
)
|
||||
}
|
||||
|
||||
if (group.type === 'missing_model') {
|
||||
return hasSingleRowWithAtMostOneReference(
|
||||
getMissingModelRows(context.missingModelGroups)
|
||||
)
|
||||
}
|
||||
|
||||
return hasSingleRowWithAtMostOneReference(
|
||||
getMissingMediaRows(context.missingMediaGroups)
|
||||
)
|
||||
}
|
||||
|
||||
function shouldUseAggregateCopyForSingleGroup(
|
||||
group: ErrorGroup,
|
||||
context: OverlayGroupContext
|
||||
): boolean {
|
||||
if (group.type === 'missing_node') {
|
||||
return context.missingPackGroups.length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'swap_nodes') {
|
||||
return context.swapNodeGroups.length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_model') {
|
||||
return getMissingModelRows(context.missingModelGroups).length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_media') {
|
||||
return getMissingMediaRows(context.missingMediaGroups).length > 1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function useErrorOverlayState() {
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { totalErrorCount, isErrorOverlayOpen } =
|
||||
storeToRefs(executionErrorStore)
|
||||
const { allErrorGroups } = useErrorGroups('')
|
||||
const { isErrorOverlayOpen } = storeToRefs(executionErrorStore)
|
||||
const {
|
||||
allErrorGroups,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups('')
|
||||
|
||||
const hasExactlyOneError = computed(() => totalErrorCount.value === 1)
|
||||
const hasMultipleErrors = computed(() => totalErrorCount.value > 1)
|
||||
const singleErrorGroup = computed(() =>
|
||||
hasExactlyOneError.value && allErrorGroups.value.length === 1
|
||||
? allErrorGroups.value[0]
|
||||
: undefined
|
||||
)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
const totalErrorCount = computed(() =>
|
||||
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const multipleErrorCountLabel = computed(() =>
|
||||
@@ -68,25 +166,38 @@ export function useErrorOverlayState() {
|
||||
)
|
||||
)
|
||||
|
||||
const singleOverlayCopy = computed(() =>
|
||||
singleErrorGroup.value
|
||||
? resolveSingleOverlayCopy(singleErrorGroup.value)
|
||||
: undefined
|
||||
)
|
||||
const aggregateOverlayCopy = computed<OverlayCopy>(() => ({
|
||||
title: multipleErrorCountLabel.value,
|
||||
message: t('errorOverlay.multipleErrorsMessage')
|
||||
}))
|
||||
|
||||
const overlayMessage = computed(() => {
|
||||
if (hasMultipleErrors.value) {
|
||||
return t('errorOverlay.multipleErrorsMessage')
|
||||
const overlayCopy = computed<OverlayCopy | undefined>(() => {
|
||||
const groups = allErrorGroups.value
|
||||
if (groups.length === 0) return undefined
|
||||
if (groups.length > 1) return aggregateOverlayCopy.value
|
||||
|
||||
const [group] = groups
|
||||
const context = {
|
||||
missingPackGroups: missingPackGroups.value,
|
||||
missingModelGroups: missingModelGroups.value,
|
||||
missingMediaGroups: missingMediaGroups.value,
|
||||
swapNodeGroups: swapNodeGroups.value
|
||||
}
|
||||
|
||||
return singleOverlayCopy.value?.message ?? ''
|
||||
if (shouldUseAggregateCopyForSingleGroup(group, context)) {
|
||||
return aggregateOverlayCopy.value
|
||||
}
|
||||
|
||||
if (isSingleLeafGroup(group, context)) {
|
||||
return resolveSingleOverlayCopy(group) ?? resolveGroupOverlayCopy(group)
|
||||
}
|
||||
|
||||
return resolveGroupOverlayCopy(group)
|
||||
})
|
||||
|
||||
const overlayTitle = computed(() =>
|
||||
hasMultipleErrors.value
|
||||
? multipleErrorCountLabel.value
|
||||
: (singleOverlayCopy.value?.title ?? errorCountLabel.value)
|
||||
)
|
||||
const overlayMessage = computed(() => overlayCopy.value?.message ?? '')
|
||||
|
||||
const overlayTitle = computed(() => overlayCopy.value?.title ?? '')
|
||||
|
||||
const isVisible = computed(
|
||||
() =>
|
||||
|
||||
@@ -218,7 +218,8 @@ onMounted(() => {
|
||||
*/
|
||||
const onMinimapToggleClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'graph_menu_minimap_toggle_clicked'
|
||||
button_id: 'graph_menu_minimap_toggle_clicked',
|
||||
element_group: 'graph_menu'
|
||||
})
|
||||
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
|
||||
}
|
||||
@@ -228,7 +229,8 @@ const onMinimapToggleClick = () => {
|
||||
*/
|
||||
const onLinkVisibilityToggleClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'graph_menu_hide_links_toggle_clicked'
|
||||
button_id: 'graph_menu_hide_links_toggle_clicked',
|
||||
element_group: 'graph_menu'
|
||||
})
|
||||
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
|
||||
const {
|
||||
hasAnySelection,
|
||||
hasGroupedNodesSelection,
|
||||
hasMultipleSelection,
|
||||
isSingleNode,
|
||||
isSingleSubgraph,
|
||||
@@ -118,7 +119,10 @@ const showSubgraphButtons = computed(() => isSingleSubgraph.value)
|
||||
|
||||
const showBypass = computed(
|
||||
() =>
|
||||
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
|
||||
isSingleNode.value ||
|
||||
isSingleSubgraph.value ||
|
||||
hasMultipleSelection.value ||
|
||||
hasGroupedNodesSelection.value
|
||||
)
|
||||
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
|
||||
const showMaskEditor = computed(() => isSingleImageNode.value)
|
||||
|
||||
@@ -65,7 +65,8 @@ describe('InfoButton', () => {
|
||||
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
button_id: 'selection_toolbox_node_info_opened',
|
||||
element_group: 'selection_toolbox'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ const onInfoClick = () => {
|
||||
if (!openNodeInfo()) return
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
button_id: 'selection_toolbox_node_info_opened',
|
||||
element_group: 'selection_toolbox'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
126
src/components/hdr/HdrViewerContent.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import HdrViewerContent from './HdrViewerContent.vue'
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
|
||||
|
||||
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
|
||||
vi.mock('@/composables/useHdrViewer', () => ({
|
||||
useHdrViewer: () => holder.viewer,
|
||||
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { loading: 'Loading', downloadImage: 'Download' },
|
||||
hdrViewer: {
|
||||
failedToLoad: 'Failed',
|
||||
exposure: 'Exposure',
|
||||
normalizeExposure: 'Auto exposure',
|
||||
channel: 'Channel',
|
||||
channels: {
|
||||
rgb: 'RGB',
|
||||
r: 'R',
|
||||
g: 'G',
|
||||
b: 'B',
|
||||
a: 'Alpha',
|
||||
luminance: 'Luminance'
|
||||
},
|
||||
sourceGamut: 'Source gamut',
|
||||
dither: 'Dither',
|
||||
clipWarnings: 'Clip warnings',
|
||||
fitView: 'Fit',
|
||||
histogram: 'Histogram',
|
||||
resolution: 'Resolution',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
mean: 'Mean',
|
||||
stdDev: 'Std dev',
|
||||
nan: 'NaN',
|
||||
inf: 'Inf'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeViewer(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
exposureStops: ref(0),
|
||||
dither: ref(true),
|
||||
clipWarnings: ref(false),
|
||||
gamut: ref('sRGB'),
|
||||
channel: ref('r'),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
dimensions: ref('512 x 512'),
|
||||
stats: ref({
|
||||
min: 0,
|
||||
max: 4,
|
||||
mean: 0.5,
|
||||
stdDev: 0.2,
|
||||
nanCount: 2,
|
||||
infCount: 1
|
||||
}),
|
||||
histogram: ref(new Uint32Array([1, 2, 3, 4])),
|
||||
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
|
||||
mount: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
fitView: vi.fn(),
|
||||
normalizeExposure: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderViewer() {
|
||||
return render(HdrViewerContent, {
|
||||
props: { imageUrl: '/api/view?filename=out.exr' },
|
||||
global: { plugins: [i18n], stubs: { Button: true } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('HdrViewerContent', () => {
|
||||
beforeEach(() => {
|
||||
holder.viewer = makeViewer()
|
||||
})
|
||||
|
||||
it('renders the full statistics set including NaN/Inf', () => {
|
||||
renderViewer()
|
||||
for (const label of [
|
||||
'Resolution',
|
||||
'Min',
|
||||
'Max',
|
||||
'Mean',
|
||||
'Std dev',
|
||||
'NaN',
|
||||
'Inf'
|
||||
]) {
|
||||
screen.getByText(label)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the pixel readout when a pixel is hovered', () => {
|
||||
renderViewer()
|
||||
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('colors the histogram according to the selected channel', () => {
|
||||
holder.viewer = makeViewer({ channel: ref('g') })
|
||||
const { container } = renderViewer()
|
||||
const path = container.querySelector('svg path')
|
||||
expect(path?.getAttribute('class')).toContain('text-green-500')
|
||||
})
|
||||
|
||||
it('renders an option for each channel mode', () => {
|
||||
renderViewer()
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Luminance' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
258
src/components/hdr/HdrViewerContent.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="flex size-full bg-base-background">
|
||||
<div class="relative flex-1">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="absolute size-full"
|
||||
data-testid="hdr-viewer-canvas"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="viewer.loading.value"
|
||||
class="absolute inset-0 flex items-center justify-center text-base-foreground"
|
||||
>
|
||||
{{ $t('g.loading') }}...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="viewer.error.value"
|
||||
role="alert"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--image-off] size-12" />
|
||||
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.pixel.value"
|
||||
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
|
||||
data-testid="hdr-pixel-readout"
|
||||
>
|
||||
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
|
||||
<div>
|
||||
{{ formatNum(viewer.pixel.value.r) }}
|
||||
{{ formatNum(viewer.pixel.value.g) }}
|
||||
{{ formatNum(viewer.pixel.value.b) }}
|
||||
<template v-if="viewer.pixel.value.a !== null">
|
||||
{{ formatNum(viewer.pixel.value.a) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
|
||||
<input
|
||||
v-model.number="viewer.exposureStops.value"
|
||||
type="range"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
class="w-full"
|
||||
:aria-label="$t('hdrViewer.exposure')"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full"
|
||||
@click="viewer.normalizeExposure"
|
||||
>
|
||||
{{ $t('hdrViewer.normalizeExposure') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.channel') }}</label>
|
||||
<select
|
||||
v-model="viewer.channel.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.channel')"
|
||||
>
|
||||
<option v-for="mode in channelModes" :key="mode" :value="mode">
|
||||
{{ channelLabels[mode] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
|
||||
<select
|
||||
v-model="viewer.gamut.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.sourceGamut')"
|
||||
>
|
||||
<option v-for="name in gamutNames" :key="name" :value="name">
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-dither"
|
||||
v-model="viewer.dither.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-dither" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.dither') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-clip"
|
||||
v-model="viewer.clipWarnings.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-clip" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.clipWarnings') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="histogramPath" class="space-y-2 p-2">
|
||||
<label>{{ $t('hdrViewer.histogram') }}</label>
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
|
||||
>
|
||||
<path
|
||||
:d="histogramPath"
|
||||
:class="histogramColorClass"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.5"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.stats.value"
|
||||
class="space-y-1 p-2 text-xs tabular-nums"
|
||||
>
|
||||
<div v-if="viewer.dimensions.value" class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.resolution') }}</span>
|
||||
<span>{{ viewer.dimensions.value }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.min') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.min) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.max') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.max) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.mean') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.stdDev') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.nanCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.nan') }}</span>
|
||||
<span>{{ viewer.stats.value.nanCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.infCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.inf') }}</span>
|
||||
<span>{{ viewer.stats.value.infCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
|
||||
{{ $t('hdrViewer.fitView') }}
|
||||
</Button>
|
||||
<Button variant="secondary" class="flex-1" @click="handleDownload">
|
||||
{{ $t('g.downloadImage') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ChannelMode } from '@/composables/useHdrViewer'
|
||||
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
|
||||
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
|
||||
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
|
||||
import { histogramToPath } from '@/utils/histogramUtil'
|
||||
|
||||
const { imageUrl } = defineProps<{ imageUrl: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const viewer = useHdrViewer()
|
||||
const gamutNames = GAMUT_NAMES
|
||||
const channelModes = CHANNEL_MODES
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
|
||||
const exposureLabel = computed(() => {
|
||||
const value = viewer.exposureStops.value
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
|
||||
)
|
||||
|
||||
const histogramColorClass = computed(() => {
|
||||
switch (viewer.channel.value) {
|
||||
case 'r':
|
||||
return 'text-red-500'
|
||||
case 'g':
|
||||
return 'text-green-500'
|
||||
case 'b':
|
||||
return 'text-blue-500'
|
||||
default:
|
||||
return 'text-base-foreground'
|
||||
}
|
||||
})
|
||||
|
||||
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
|
||||
rgb: t('hdrViewer.channels.rgb'),
|
||||
r: t('hdrViewer.channels.r'),
|
||||
g: t('hdrViewer.channels.g'),
|
||||
b: t('hdrViewer.channels.b'),
|
||||
a: t('hdrViewer.channels.a'),
|
||||
luminance: t('hdrViewer.channels.luminance')
|
||||
}))
|
||||
|
||||
function formatNum(value: number): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
|
||||
? value.toExponential(3)
|
||||
: value.toFixed(4)
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
downloadFile(toFullResolutionUrl(imageUrl))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
|
||||
})
|
||||
</script>
|
||||
@@ -27,6 +27,7 @@
|
||||
: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"
|
||||
@@ -166,6 +167,7 @@ const {
|
||||
canExport,
|
||||
materialModes,
|
||||
hasSkeleton,
|
||||
sourceFormat,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
|
||||
<ExportControls
|
||||
v-if="showExportControls"
|
||||
:source-format="sourceFormat"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
|
||||
@@ -134,7 +135,8 @@ const {
|
||||
canUseHdri = true,
|
||||
canUseBackgroundImage = true,
|
||||
materialModes = ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton = false
|
||||
hasSkeleton = false,
|
||||
sourceFormat = null
|
||||
} = defineProps<{
|
||||
canUseGizmo?: boolean
|
||||
canUseLighting?: boolean
|
||||
@@ -143,6 +145,7 @@ const {
|
||||
canUseBackgroundImage?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
|
||||
@@ -59,6 +59,7 @@ 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),
|
||||
|
||||
@@ -82,7 +82,10 @@
|
||||
</div>
|
||||
|
||||
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
<ExportControls
|
||||
:source-format="viewer.sourceFormat.value"
|
||||
@export-model="viewer.exportModel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,12 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
function renderComponent(
|
||||
onExportModel?: (format: string) => void,
|
||||
sourceFormat: string | null = null
|
||||
) {
|
||||
const utils = render(ExportControls, {
|
||||
props: { onExportModel },
|
||||
props: { onExportModel, sourceFormat },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
@@ -63,6 +66,23 @@ 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()
|
||||
|
||||
|
||||
@@ -35,9 +35,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, 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
|
||||
@@ -45,12 +50,7 @@ const emit = defineEmits<{
|
||||
|
||||
const showExportFormats = ref(false)
|
||||
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
|
||||
function toggleExportFormats() {
|
||||
showExportFormats.value = !showExportFormats.value
|
||||
|
||||
@@ -72,9 +72,12 @@ const i18n = createI18n({
|
||||
messages: { en: { load3d: { export: 'Export' } } }
|
||||
})
|
||||
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
function renderComponent(
|
||||
onExportModel?: (format: string) => void,
|
||||
sourceFormat: string | null = null
|
||||
) {
|
||||
const utils = render(ViewerExportControls, {
|
||||
props: { onExportModel },
|
||||
props: { onExportModel, sourceFormat },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
@@ -114,4 +117,32 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
@@ -34,20 +34,30 @@ 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 = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
70
src/components/palette/PaletteSwatchRow.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PaletteSwatchRow from './PaletteSwatchRow.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
|
||||
})
|
||||
|
||||
function renderRow(modelValue: string[], max = 5) {
|
||||
return render(PaletteSwatchRow, {
|
||||
props: { modelValue, max },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
|
||||
const calls = emitted()['update:modelValue']
|
||||
return calls[calls.length - 1][0]
|
||||
}
|
||||
|
||||
describe('PaletteSwatchRow', () => {
|
||||
it('renders one swatch per color', () => {
|
||||
const { container } = renderRow(['#ff0000', '#00ff00'])
|
||||
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
it('removes a color on right click', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
|
||||
expect(lastEmit(emitted)).toEqual(['#00ff00'])
|
||||
})
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
|
||||
button: 0,
|
||||
clientX: 5,
|
||||
clientY: 5
|
||||
})
|
||||
expect(emitted()['update:modelValue']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
48
src/components/palette/PaletteSwatchRow.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
|
||||
:title="t('palette.addColor')"
|
||||
@click="addColor"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
</script>
|
||||
54
src/components/palette/WidgetColors.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WidgetColors from './WidgetColors.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
|
||||
})
|
||||
|
||||
function renderWidget(modelValue: string[], widget?: { name: string }) {
|
||||
return render(WidgetColors, {
|
||||
props: { modelValue, widget },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.()
|
||||
})
|
||||
|
||||
describe('WidgetColors', () => {
|
||||
it('renders the palette swatch row for each color', () => {
|
||||
renderWidget(['#ff0000', '#00ff00'])
|
||||
const root = screen.getByTestId('colors')
|
||||
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the widget name as an inline label', () => {
|
||||
renderWidget(['#ff0000'], { name: 'color_palette' })
|
||||
expect(screen.getByText('color_palette')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits an updated palette when a color is added', async () => {
|
||||
const { emitted } = renderWidget([])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
const calls = emitted()['update:modelValue'] as unknown[][]
|
||||
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
|
||||
})
|
||||
|
||||
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
|
||||
const { container } = renderWidget(['#ff0000'])
|
||||
const onDocMove = vi.fn()
|
||||
document.addEventListener('pointermove', onDocMove)
|
||||
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
|
||||
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
|
||||
expect(onDocMove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
29
src/components/palette/WidgetColors.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-full items-center gap-2"
|
||||
data-testid="colors"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<span
|
||||
v-if="widget?.name"
|
||||
class="shrink-0 truncate text-node-component-slot-text"
|
||||
>
|
||||
{{ widget.label || widget.name }}
|
||||
</span>
|
||||
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import PaletteSwatchRow from './PaletteSwatchRow.vue'
|
||||
|
||||
const MAX_COLORS = 16
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string[]>({ default: () => [] })
|
||||
</script>
|
||||
@@ -14,6 +14,7 @@ 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'
|
||||
@@ -106,6 +107,10 @@ const isSingleSubgraphNode = computed(() => {
|
||||
})
|
||||
|
||||
function closePanel() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'right_side_panel_closed',
|
||||
element_group: 'right_side_panel'
|
||||
})
|
||||
rightSidePanelStore.closePanel()
|
||||
}
|
||||
|
||||
|
||||
71
src/components/rightSidePanel/errors/ErrorCardSection.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
|
||||
<div class="flex min-h-8 w-full items-center gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ title }}
|
||||
</span>
|
||||
</button>
|
||||
<slot name="actions" />
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
:aria-label="
|
||||
collapse ? t('rightSidePanel.expand') : t('rightSidePanel.collapse')
|
||||
"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-up] size-4 text-muted-foreground transition-transform group-hover:text-base-foreground',
|
||||
collapse && '-rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="!collapse" :id="bodyId">
|
||||
<slot />
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
|
||||
const {
|
||||
title,
|
||||
count,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const bodyId = useId()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,29 +1,31 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
class="flex min-h-8 flex-wrap items-center gap-2"
|
||||
>
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
@@ -34,7 +36,7 @@
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
@@ -49,7 +51,7 @@
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
@@ -59,29 +61,29 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
>
|
||||
<p
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{{ getInlineMessage(error) }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="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 text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
@@ -96,13 +98,13 @@
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
|
||||
'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
@@ -115,60 +117,61 @@
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-1 py-1">
|
||||
<span
|
||||
class="text-xs font-semibold text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto">
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
<i class="icon-[lucide--github] size-4" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div data-testid="missing-node-card" class="px-4 pb-2">
|
||||
<div data-testid="missing-node-card" class="px-3">
|
||||
<!-- Core node version warning (OSS only) -->
|
||||
<div
|
||||
v-if="!isCloud && hasMissingCoreNodes"
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
@@ -75,7 +75,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-md text-xs"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
: 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'
|
||||
)
|
||||
"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<i
|
||||
@@ -64,7 +64,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
@@ -80,7 +80,7 @@
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
@@ -89,7 +89,7 @@
|
||||
<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"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
@@ -99,7 +99,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
@@ -122,10 +122,10 @@
|
||||
</div>
|
||||
<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="ml-auto flex h-6 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm bg-secondary-background px-2 py-1 text-xs 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">
|
||||
<span class="text-foreground min-w-0 truncate text-xs">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
@@ -150,7 +150,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -163,7 +163,7 @@
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
'm-0 list-none p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -190,7 +190,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -199,7 +199,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
@@ -241,7 +241,7 @@ 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'
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word outline-none focus:outline-none rounded-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
@@ -78,6 +78,10 @@ describe('TabErrors.vue', () => {
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorsDetected: 'Error detected | Errors detected',
|
||||
resolveBeforeRun: 'Resolve before running the workflow',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
@@ -118,9 +122,6 @@ describe('TabErrors.vue', () => {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
@@ -211,7 +212,13 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Errors detected')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
@@ -326,6 +333,9 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
|
||||
1
|
||||
)
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('1')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -404,7 +414,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -414,6 +424,40 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts missing models per file when several share one directory', () => {
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-a.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-b.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
}
|
||||
] satisfies MissingModelCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-model')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
@@ -431,7 +475,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
@@ -453,7 +497,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
@@ -495,6 +539,12 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-media')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
@@ -503,6 +553,73 @@ describe('TabErrors.vue', () => {
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
|
||||
})
|
||||
|
||||
it('sums the summary hero count across error types', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'Node'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: model',
|
||||
extra_info: { input_name: 'model' }
|
||||
},
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: positive',
|
||||
extra_info: { input_name: 'positive' }
|
||||
}
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: clip',
|
||||
extra_info: { input_name: 'clip' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
missingMedia: {
|
||||
missingMediaCandidates: [
|
||||
{
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'a.png',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '4',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'b.png',
|
||||
isMissing: true
|
||||
}
|
||||
]
|
||||
} satisfies { missingMediaCandidates: MissingMediaCandidate[] }
|
||||
})
|
||||
|
||||
// 3 validation items + 2 missing media references
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('5')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders swap node rows below the section display message', () => {
|
||||
const swapNode = {
|
||||
type: 'OldSampler',
|
||||
@@ -526,18 +643,20 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'OldSampler' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('KSampler')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Replace Node/ })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
|
||||
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
@@ -555,11 +674,8 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-header-refresh')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()
|
||||
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,49 +11,62 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
key="empty"
|
||||
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span class="truncate text-destructive-background-hover">
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
</span>
|
||||
</span>
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="group.count"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
@@ -62,7 +75,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
@@ -83,7 +96,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
@@ -94,9 +107,10 @@
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
@@ -112,7 +126,6 @@
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
{{ t('rightSidePanel.missingModels.refresh') }}
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
@@ -129,142 +142,142 @@
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="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 text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
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-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
||||
<div
|
||||
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
@@ -302,16 +315,14 @@ 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'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
@@ -320,7 +331,6 @@ 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'
|
||||
@@ -328,6 +338,7 @@ import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { isExecutionItemListGroup } from './executionItemList'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
@@ -348,7 +359,6 @@ 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 } =
|
||||
@@ -362,37 +372,6 @@ const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
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' &&
|
||||
group.cards.length > 0 &&
|
||||
group.cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
@@ -424,14 +403,6 @@ function compareExecutionItemListEntry(
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionGroupCount(group: ErrorGroup) {
|
||||
if (group.type !== 'execution') return 0
|
||||
if (isExecutionItemListGroup(group)) {
|
||||
return group.cards.reduce((count, card) => count + card.errors.length, 0)
|
||||
}
|
||||
return group.cards.length
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
@@ -464,20 +435,17 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
const missingModelDownloadableModels = computed(() => {
|
||||
if (isCloud) return []
|
||||
|
||||
return getDownloadableModels(missingModelGroups.value)
|
||||
})
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() =>
|
||||
!isCloud &&
|
||||
missingModelGroups.value.length > 0 &&
|
||||
missingModelDownloadableModels.value.length === 0
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
function handleMissingModelRefresh() {
|
||||
if (missingModelStore.isRefreshingMissingModels) return
|
||||
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
|
||||
21
src/components/rightSidePanel/errors/executionItemList.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ErrorCardData, ErrorGroup } from './types'
|
||||
|
||||
export function shouldRenderExecutionItemList(cards: ErrorCardData[]): boolean {
|
||||
return (
|
||||
cards.length > 0 &&
|
||||
cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function isExecutionItemListGroup(group: ErrorGroup): boolean {
|
||||
return (
|
||||
group.type === 'execution' && shouldRenderExecutionItemList(group.cards)
|
||||
)
|
||||
}
|
||||