Compare commits

..

4 Commits

Author SHA1 Message Date
Benjamin Lu
e798608843 test: type github stars fetch mocks 2026-05-06 08:01:16 -07:00
Benjamin Lu
4f94104faf test: avoid duplicating github stars fallback 2026-05-06 07:58:26 -07:00
Benjamin Lu
ca48ec86eb fix: fall back to latest github star count 2026-05-06 07:56:02 -07:00
Benjamin Lu
72b5f6be68 fix: fetch website GitHub stars once per build 2026-05-04 20:09:21 -07:00
174 changed files with 1125 additions and 11530 deletions

View File

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

View File

@@ -4,9 +4,6 @@ name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
branches-ignore:
- 'core/**'
- 'cloud/**'
paths:
- 'apps/website/**'
- 'packages/design-system/**'

View File

@@ -61,7 +61,7 @@ test.describe('Payment failed page @smoke', () => {
test('shows failure heading and subtitle', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: /Unable to complete payment/i,
name: /Payment was not completed/i,
level: 1
})
).toBeVisible()
@@ -102,7 +102,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
await expect(page).toHaveTitle('支付失败 — Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })
page.getByRole('heading', { name: '支付未完成', level: 1 })
).toBeVisible()
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
'href',

View File

@@ -126,7 +126,6 @@ test.describe('Overflow guards', { tag: '@visual' }, () => {
const pages = [
'/',
'/cloud',
'/cloud/enterprise',
'/cloud/pricing',
'/contact',
'/download',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -27,7 +27,6 @@
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const photos = [
{
src: 'https://media.comfy.org/website/careers/team0.webp',
@@ -15,34 +17,45 @@ const photos = [
{
src: 'https://media.comfy.org/website/careers/team3.webp',
alt: 'Team on a boat'
},
{
src: 'https://media.comfy.org/website/careers/team4.webp',
alt: 'Teammates posing at a restaurant'
},
{
src: 'https://media.comfy.org/website/careers/team5.webp',
alt: 'Teammates at a social gathering'
},
{
src: 'https://media.comfy.org/website/careers/team6.webp',
alt: 'Team sailing at golden hour'
},
{
src: 'https://media.comfy.org/website/careers/team7.webp',
alt: 'Team on a sailboat at sunset'
}
]
const loopedPhotos = [...photos, ...photos, ...photos]
const scrollRef = ref<HTMLElement>()
function onScroll() {
const el = scrollRef.value
if (!el) return
const third = el.scrollWidth / 3
const maxScroll = el.scrollWidth - el.clientWidth
if (el.scrollLeft >= maxScroll - 1) {
el.scrollLeft -= third
} else if (el.scrollLeft <= 1) {
el.scrollLeft += third
}
}
onMounted(() => {
const el = scrollRef.value
if (el) {
el.scrollLeft = el.scrollWidth / 3
}
})
</script>
<template>
<section class="py-12 md:py-24">
<div
ref="scrollRef"
class="flex gap-4 overflow-x-auto px-6 md:gap-6 md:px-20"
style="scrollbar-width: none"
@scroll="onScroll"
>
<div
v-for="(photo, i) in photos"
v-for="(photo, i) in loopedPhotos"
:key="i"
class="aspect-3/4 h-64 shrink-0 md:h-96"
>

View File

@@ -13,7 +13,7 @@ const { stars } = defineProps<{
target="_blank"
rel="noopener noreferrer"
:aria-label="`ComfyUI on GitHub ${stars} stars`"
class="hidden shrink-0 items-center gap-1 lg:flex"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<NodeBadge
:segments="[{ text: stars }]"
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
size-class="h-5 sm:h-5"
/>
<span
class="bg-primary-comfy-yellow block size-6 shrink-0"
class="bg-primary-comfy-yellow block size-7"
aria-hidden="true"
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
/>

View File

@@ -1,31 +1,24 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { useHeroLogo } from '../../composables/useHeroLogo'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logoContainer = ref<HTMLElement>()
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
</script>
<template>
<section
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"
class="relative flex aspect-square w-full flex-1 items-center justify-center"
>
<img
v-show="!logoLoaded"
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
alt="Comfy logo"
class="w-3/5"
<div class="relative flex-1">
<video
src="https://media.comfy.org/website/homepage/hero-logo-seq.webm"
autoplay
loop
muted
playsinline
class="w-full"
/>
</div>

View File

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

View File

@@ -35,20 +35,20 @@ onMounted(() => {
<template>
<section
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden pt-16 lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pt-[min(8vw,10rem)] lg:pb-[min(8vw,10rem)]"
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pb-[min(8vw,10rem)]"
>
<!-- Illustration (overlaps text slightly; stacks above on mobile, right on lg) -->
<div
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:translate-y-[40px] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<svg
ref="svgRef"
class="block size-full overflow-visible"
class="block size-full"
viewBox="0 0 1600 1046"
fill="none"
aria-hidden="true"
>
<g>
<g clip-path="url(#enterpriseHeroClip)">
<rect width="1600" height="1046" fill="#211927" />
<rect
width="800"
@@ -84,7 +84,7 @@ onMounted(() => {
/>
<!-- Exploding block cluster -->
<g class="block-cluster" clip-path="url(#enterpriseHeroBlockClip)">
<g class="block-cluster">
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
@@ -353,7 +353,7 @@ onMounted(() => {
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
<clipPath id="enterpriseHeroBlockClip">
<clipPath id="enterpriseHeroClip">
<rect width="1600" height="1046" fill="white" />
</clipPath>
</defs>

View File

@@ -8,9 +8,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="bg-transparency-white-t4 relative z-20 p-4 text-center lg:px-20 lg:py-8"
>
<section class="bg-transparency-white-t4 p-4 text-center lg:px-20 lg:py-8">
<p
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
>

View File

@@ -1,328 +0,0 @@
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import * as THREE from 'three'
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
import { prefersReducedMotion } from './useReducedMotion'
const IMAGE_COUNT = 16
const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
const SVG_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 404"><path fill="#000000" d="M296.597 302.576C297.299 300.205 297.682 297.705 297.682 295.078C297.682 280.529 285.938 268.736 271.45 268.736H153.883C147.564 268.8 142.395 263.673 142.395 257.328C142.395 256.174 142.586 255.084 142.841 254.059L174.499 143.309C175.839 138.438 180.307 134.849 185.541 134.849L303.554 134.72C328.446 134.72 349.444 117.864 355.763 94.8555L373.506 33.1353C374.081 30.9562 374.4 28.5848 374.4 26.2134C374.4 11.7288 362.72 0 348.295 0H205.518C180.754 0 159.819 16.7279 153.373 39.4804L141.373 81.5886C139.969 86.3954 135.565 89.9205 130.332 89.9205H96.0573C71.4845 89.9205 50.7412 106.328 44.1034 128.824L0.957382 280.144C0.319127 282.387 0 284.823 0 287.258C0 301.807 11.7439 313.6 26.2323 313.6H59.9321C66.2508 313.6 71.4207 318.727 71.4207 325.137C71.4207 326.226 71.293 327.316 70.9739 328.341L59.0385 370.065C58.4641 372.308 58.0811 374.615 58.0811 376.987C58.0811 391.471 69.7612 403.2 84.1857 403.2L227.027 403.072C251.855 403.072 272.79 386.28 279.172 363.399L296.533 302.64L296.597 302.576Z"/></svg>`
interface HeroLogoConfig {
speed: number
tiltX: number
tiltZ: number
zoom: number
fov: number
logoColor: string
extrudeDepth: number
cursorTiltStrength: number
bgScale: number
slideDuration: number
}
const DEFAULTS: HeroLogoConfig = {
speed: 1,
tiltX: -0.1,
tiltZ: -0.1,
zoom: 7,
fov: 50,
logoColor: '#F2FF59',
extrudeDepth: 200,
cursorTiltStrength: 0.5,
bgScale: 0.8,
slideDuration: 0.4
}
function buildImageUrls(): string[] {
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
const index = String(i).padStart(5, '0')
return `${BASE_URL}/image_sequence_${index}.webp`
})
}
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(
containerRef: Ref<HTMLElement | undefined>,
config: Partial<HeroLogoConfig> = {}
) {
const cfg = { ...DEFAULTS, ...config }
const loaded = ref(false)
let cleanup: (() => void) | undefined
onMounted(async () => {
try {
const container = containerRef.value
if (!container || prefersReducedMotion()) return
const { width, height } = container.getBoundingClientRect()
const renderer = new THREE.WebGLRenderer({
antialias: true,
stencil: true,
alpha: true
})
renderer.setSize(width, height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.domElement.style.position = 'absolute'
renderer.domElement.style.inset = '0'
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.opacity = '0'
renderer.domElement.setAttribute('aria-hidden', 'true')
container.appendChild(renderer.domElement)
let disposed = false
const teardowns: Array<() => void> = []
cleanup = () => {
disposed = true
teardowns.forEach((fn) => fn())
}
teardowns.push(() => {
renderer.dispose()
renderer.domElement.remove()
})
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(
cfg.fov,
width / height,
0.1,
1000
)
camera.position.z = cfg.zoom
// SVG shape
const shapes = parseShapes()
const tempGeo = new THREE.ShapeGeometry(shapes)
tempGeo.computeBoundingBox()
const bb = tempGeo.boundingBox!
const cx = (bb.max.x + bb.min.x) / 2
const cy = (bb.max.y + bb.min.y) / 2
const scaleFactor = 3 / (bb.max.y - bb.min.y)
tempGeo.dispose()
// Image sequence textures — load first frame eagerly, rest lazily
const urls = buildImageUrls()
const textures = await loadTextures(urls.slice(0, 1))
if (disposed) return
renderer.domElement.style.opacity = '1'
loaded.value = true
loadTextures(urls.slice(1)).then((rest) => {
if (!disposed) textures.push(...rest)
})
// Background plane (stencil read)
const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
const bgPlaneMat = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 1,
map: textures[0] ?? null,
depthTest: false,
depthWrite: false,
stencilWrite: true,
stencilFunc: THREE.EqualStencilFunc,
stencilRef: 1,
stencilFail: THREE.KeepStencilOp,
stencilZFail: THREE.KeepStencilOp,
stencilZPass: THREE.KeepStencilOp
})
const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
bgPlane.renderOrder = 1
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
scene.add(bgPlane)
// Logo group
const group = new THREE.Group()
scene.add(group)
const s = scaleFactor
const depth = cfg.extrudeDepth
// Front face
const shapeGeo = new THREE.ShapeGeometry(shapes)
shapeGeo.translate(-cx, -cy, 0)
shapeGeo.scale(s, -s, s)
const shapeMat = new THREE.MeshBasicMaterial({
color: cfg.logoColor,
side: THREE.DoubleSide,
depthTest: false,
depthWrite: false,
transparent: true
})
const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
logoMesh.renderOrder = 2
group.add(logoMesh)
// Extrusion stencil mask
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
depth,
bevelEnabled: false
})
extrudeGeo.translate(-cx, -cy, -depth)
extrudeGeo.scale(s, -s, s)
const extrudeMat = new THREE.MeshBasicMaterial({
colorWrite: false,
depthWrite: true,
depthTest: true,
stencilWrite: true,
stencilRef: 1,
stencilFunc: THREE.AlwaysStencilFunc,
stencilZPass: THREE.ReplaceStencilOp,
stencilFail: THREE.KeepStencilOp,
stencilZFail: THREE.KeepStencilOp,
side: THREE.DoubleSide
})
const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
extrudeMesh.renderOrder = 0
group.add(extrudeMesh)
// Interaction
let isDragging = false
let previousX = 0
let dragVelocity = 0
let currentTiltX = 0
let currentTiltY = 0
let pointerX = 0
let pointerY = 0
let rotationT = 0
let currentSlide = 0
let slideTimer = 0
let animationId = 0
function onMouseMove(e: MouseEvent) {
pointerX = (e.clientX / window.innerWidth) * 2 - 1
pointerY = (e.clientY / window.innerHeight) * 2 - 1
}
function onPointerDown(e: PointerEvent) {
isDragging = true
dragVelocity = 0
previousX = e.clientX
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return
dragVelocity = (e.clientX - previousX) * 0.005
rotationT += dragVelocity
previousX = e.clientX
}
function onPointerUp() {
isDragging = false
}
function onResize() {
const rect = container!.getBoundingClientRect()
camera.aspect = rect.width / rect.height
camera.updateProjectionMatrix()
renderer.setSize(rect.width, rect.height)
}
window.addEventListener('mousemove', onMouseMove)
renderer.domElement.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('resize', onResize)
const clock = new THREE.Clock()
function animate() {
if (disposed) return
animationId = requestAnimationFrame(animate)
const dt = clock.getDelta()
if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
dragVelocity *= 0.95
rotationT += dragVelocity
} else if (!isDragging) {
dragVelocity = 0
}
rotationT += cfg.speed * dt
currentTiltX += (pointerY - currentTiltX) * 0.08
currentTiltY += (pointerX - currentTiltY) * 0.08
group.rotation.y = rotationT % (Math.PI * 2)
group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
group.rotation.z = cfg.tiltZ
if (textures.length > 1) {
slideTimer += dt
if (slideTimer >= cfg.slideDuration) {
slideTimer = 0
currentSlide = (currentSlide + 1) % textures.length
bgPlaneMat.map = textures[currentSlide]
bgPlaneMat.needsUpdate = true
}
}
renderer.render(scene, camera)
}
animate()
teardowns.push(
() => cancelAnimationFrame(animationId),
() => window.removeEventListener('mousemove', onMouseMove),
() =>
renderer.domElement.removeEventListener('pointerdown', onPointerDown),
() => window.removeEventListener('pointermove', onPointerMove),
() => window.removeEventListener('pointerup', onPointerUp),
() => window.removeEventListener('resize', onResize),
() => bgPlaneGeo.dispose(),
() => bgPlaneMat.dispose(),
() => shapeGeo.dispose(),
() => shapeMat.dispose(),
() => extrudeGeo.dispose(),
() => extrudeMat.dispose(),
() => textures.forEach((tex) => tex.dispose())
)
} catch (err) {
console.error('[useHeroLogo] initialization failed:', err)
cleanup?.()
}
})
onUnmounted(() => {
cleanup?.()
})
return { loaded }
}

View File

@@ -1119,10 +1119,6 @@ const translations = {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: '3 concurrent API jobs',
'zh-CN': '3 个并发 API 任务'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
@@ -1147,10 +1143,6 @@ const translations = {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: '5 concurrent API jobs',
'zh-CN': '5 个并发 API 任务'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {
@@ -1607,7 +1599,7 @@ const translations = {
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
'nav.blogs': { en: 'Blogs', 'zh-CN': '博客' },
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
'nav.docs': { en: 'Docs', 'zh-CN': '文档' },
@@ -3506,6 +3498,18 @@ const translations = {
en: 'Dale Carman | Co-founder @ Groove Jones',
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
},
'customers.detail.groove-jones.topic-10.block.2.label': {
en: 'GROOVE JONES CONTRIBUTORS',
'zh-CN': 'GROOVE JONES 贡献者'
},
'customers.detail.groove-jones.topic-10.block.2.name': {
en: 'TBD',
'zh-CN': '待补充'
},
'customers.detail.groove-jones.topic-10.block.2.role': {
en: 'TBD',
'zh-CN': '待补充'
},
// Contact FormSection
'contact.form.badge': {
@@ -3690,8 +3694,8 @@ const translations = {
'zh-CN': '支付'
},
'payment.failed.title': {
en: 'Unable to complete payment',
'zh-CN': '无法完成支付'
en: 'Payment was not completed',
'zh-CN': '支付未完成'
},
'payment.failed.subtitle': {
en: "Your payment didn't go through and you have not been charged. Reach out to support or read the subscription docs if you need help.",

View File

@@ -5,12 +5,11 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { fetchGitHubStarsForBuild, formatStarCount } from '../utils/github'
interface Props {
title: string
description?: string
keywords?: string[]
ogImage?: string
noindex?: boolean
}
@@ -18,19 +17,16 @@ interface Props {
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const rawStars = await fetchGitHubStarsForBuild()
const githubStars = rawStars ? formatStarCount(rawStars) : ''
const gtmId = 'GTM-NP9JM6K7'
@@ -66,7 +62,6 @@ const websiteJsonLd = {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
{keywordsContent && <meta name="keywords" content={keywordsContent} />}
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>

View File

@@ -7,14 +7,9 @@ import AudienceSection from '../../components/product/cloud/AudienceSection.vue'
import PricingSection from '../../components/product/cloud/PricingSection.vue'
import ProductCardsSection from '../../components/product/cloud/ProductCardsSection.vue'
import FAQSection from '../../components/product/cloud/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud — AI in the Cloud"
description={t('cloud.hero.subtitle', 'en')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
>
<BaseLayout title="Comfy Cloud — AI in the Cloud">
<HeroSection />
<ReasonSection />
<AIModelsSection />

View File

@@ -7,14 +7,9 @@ import ReasonSection from '../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title="Download Comfy — Run AI Locally"
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']}
>
<BaseLayout title="Download Comfy — Run AI Locally">
<CloudBannerSection />
<HeroSection client:load />
<ReasonSection />

View File

@@ -8,14 +8,9 @@ import UseCaseSection from '../components/home/UseCaseSection.vue'
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
import GetStartedSection from '../components/home/GetStartedSection.vue'
import BuildWhatSection from '../components/home/BuildWhatSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title="Comfy — Professional Control of Visual AI"
description={t('hero.subtitle', 'en')}
keywords={['comfyui app', 'comfyui web app', 'comfy ui application', 'comfyui application', 'comfy app', 'comfyui', 'visual ai app', 'node-based ai', 'generative ai workflows']}
>
<BaseLayout title="Comfy — Professional Control of Visual AI">
<HeroSection client:load />
<SocialProofBarSection />
<ProductShowcaseSection client:load />

View File

@@ -7,14 +7,9 @@ import AudienceSection from '../../../components/product/cloud/AudienceSection.v
import PricingSection from '../../../components/product/cloud/PricingSection.vue'
import ProductCardsSection from '../../../components/product/cloud/ProductCardsSection.vue'
import FAQSection from '../../../components/product/cloud/FAQSection.vue'
import { t } from '../../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud — 云端 AI"
description={t('cloud.hero.subtitle', 'zh-CN')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
>
<BaseLayout title="Comfy Cloud — 云端 AI">
<HeroSection locale="zh-CN" />
<ReasonSection locale="zh-CN" />
<AIModelsSection locale="zh-CN" />

View File

@@ -7,14 +7,9 @@ import ReasonSection from '../../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title="下载 — Comfy"
description={t('download.hero.subtitle', 'zh-CN')}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<BaseLayout title="下载 — Comfy">
<CloudBannerSection locale="zh-CN" />
<HeroSection locale="zh-CN" client:load />
<ReasonSection locale="zh-CN" />

View File

@@ -8,14 +8,9 @@ import UseCaseSection from '../../components/home/UseCaseSection.vue'
import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightSection.vue'
import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy — 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
>
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
<HeroSection locale="zh-CN" client:load />
<SocialProofBarSection />
<ProductShowcaseSection locale="zh-CN" client:load />

View File

@@ -1,11 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchGitHubStars, formatStarCount } from './github'
import {
fetchGitHubStars,
fetchGitHubStarsForBuild,
formatStarCount,
resetGitHubStarsFetcherForTests
} from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
afterEach(() => {
resetGitHubStarsFetcherForTests()
vi.restoreAllMocks()
if (savedOverride === undefined)
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
@@ -16,17 +22,47 @@ describe('fetchGitHubStars', () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
await expect(fetchGitHubStars()).resolves.toBe(110000)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails fast when the build-time override is malformed', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
await expect(fetchGitHubStars()).rejects.toThrow(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
})
it('memoizes build-time star fetches within a single process', async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify({ stargazers_count: 110000 }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
)
const [a, b] = await Promise.all([
fetchGitHubStarsForBuild(fetchImpl),
fetchGitHubStarsForBuild(fetchImpl)
])
expect(a).toBe(110000)
expect(b).toBe(110000)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('falls back to the last known star count for build-time fetch failures', async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () => new Response(null, { status: 403 })
)
const fallback = await fetchGitHubStarsForBuild(fetchImpl)
expect(Number.isSafeInteger(fallback)).toBe(true)
expect(fallback).toBeGreaterThan(0)
})
})
describe('formatStarCount', () => {

View File

@@ -1,17 +1,35 @@
const GITHUB_REPO_API_URL = 'https://api.github.com/repos/Comfy-Org/ComfyUI'
// Fetched from GitHub on 2026-05-06.
const GITHUB_STARS_FALLBACK = 111_605
let inflight: Promise<number> | undefined
export function resetGitHubStarsFetcherForTests(): void {
inflight = undefined
}
export function fetchGitHubStarsForBuild(
fetchImpl: typeof fetch = fetch
): Promise<number> {
inflight ??= fetchGitHubStars(fetchImpl).then(
(stars) => stars ?? GITHUB_STARS_FALLBACK
)
return inflight
}
export async function fetchGitHubStars(
owner: string,
repo: string
fetchImpl: typeof fetch = fetch
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
const res = await fetchImpl(GITHUB_REPO_API_URL, {
headers: { Accept: 'application/vnd.github.v3+json' }
})
if (!res.ok) return null
const data = await res.json()
return data.stargazers_count ?? null
const data: unknown = await res.json()
return readStargazerCount(data)
} catch {
return null
}
@@ -42,3 +60,9 @@ function readGitHubStarsOverride(): number | undefined {
return count
}
function readStargazerCount(data: unknown): number | null {
if (data === null || typeof data !== 'object') return null
const count = (data as { stargazers_count?: unknown }).stargazers_count
return typeof count === 'number' ? count : null
}

View File

@@ -1,232 +0,0 @@
{
"id": "14af6003-d4ee-4dee-8e3d-cbff2e5519b3",
"revision": 0,
"last_node_id": 205,
"last_link_id": 383,
"nodes": [
{
"id": 205,
"type": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
"pos": [4720, 5820],
"size": [400, 470],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"label": "lotus_model",
"name": "unet_name_1",
"type": "COMBO",
"widget": {
"name": "unet_name_1"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"proxyWidgets": [["76", "unet_name"]]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
"version": 1,
"state": {
"lastGroupId": 8,
"lastNodeId": 205,
"lastLinkId": 383,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Depth to Image (Z-Image-Turbo)",
"inputNode": {
"id": -10,
"bounding": [28, 4936, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [1599, 4936, 128, 68]
},
"inputs": [
{
"id": "80e6915f-5d59-4d6b-a197-d8c565ad2922",
"name": "unet_name_1",
"type": "COMBO",
"linkIds": [258],
"pos": [132, 4960]
}
],
"outputs": [
{
"id": "47f9a22d-6619-4917-9447-a7d5d08dceb5",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [],
"pos": [1623, 4960]
}
],
"widgets": [],
"nodes": [
{
"id": 76,
"type": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
"pos": [250, 4910],
"size": [400, 210],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 258
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"proxyWidgets": [["203", "unet_name"]]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 258,
"origin_id": -10,
"origin_slot": 0,
"target_id": 76,
"target_slot": 0,
"type": "COMBO"
}
],
"extra": {
"workflowRendererVersion": "LG",
"ds": {
"scale": 1,
"offset": [-30, -4760]
}
}
},
{
"id": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
"version": 1,
"state": {
"lastGroupId": 8,
"lastNodeId": 205,
"lastLinkId": 383,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Image to Depth Map (Lotus)",
"inputNode": {
"id": -10,
"bounding": [-60, -173, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [1650, -173, 128, 68]
},
"inputs": [
{
"id": "d721b249-fd2a-441b-9a78-2805f04e2644",
"name": "unet_name",
"type": "COMBO",
"linkIds": [256],
"pos": [44, -149]
}
],
"outputs": [
{
"id": "2ec278bd-0b66-4b30-9c5b-994d5f638214",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [],
"pos": [1674, -149]
}
],
"widgets": [],
"nodes": [
{
"id": 203,
"type": "UNETLoader",
"pos": [180, -200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 256
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": []
}
],
"properties": {},
"widgets_values": ["lotus-depth-d-v1-1.safetensors", "default"]
}
],
"groups": [],
"links": [
{
"id": 256,
"origin_id": -10,
"origin_slot": 0,
"target_id": 203,
"target_slot": 0,
"type": "COMBO"
}
],
"extra": {
"workflowRendererVersion": "LG",
"ds": {
"scale": 1,
"offset": [40, 350]
}
}
}
]
},
"config": {},
"extra": {
"workflowRendererVersion": "LG",
"ds": {
"scale": 1,
"offset": [-4500, -5670]
}
},
"version": 0.4
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,179 +0,0 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [225, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [270, 108],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [270, 104],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [225, 104],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -352,12 +352,6 @@ export class ComfyPage {
await nextFrame(this.page)
}
async idleFrames(count: number) {
for (let i = 0; i < count; i++) {
await this.nextFrame()
}
}
async delay(ms: number) {
return sleep(ms)
}
@@ -466,15 +460,10 @@ export const testComfySnapToGridGridSize = 50
const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
export const comfyPageFixture = base.extend<{
initialFeatureFlags: Record<string, unknown>
comfyPage: ComfyPage
comfyMouse: ComfyMouse
comfyFiles: ComfyFiles
}>({
// Allows configuring feature flags for tests with before initial setup:
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
initialFeatureFlags: [{}, { option: true }],
page: async ({ page, browserName }, use) => {
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
return use(page)
@@ -491,7 +480,7 @@ export const comfyPageFixture = base.extend<{
await mcr.add(coverage)
},
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
comfyPage: async ({ page, request }, use, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
@@ -535,10 +524,6 @@ export const comfyPageFixture = base.extend<{
await comfyPage.cloudAuth.mockAuth()
}
if (Object.keys(initialFeatureFlags).length > 0) {
await comfyPage.featureFlags.seedFlags(initialFeatureFlags)
}
await comfyPage.setup()
if (isVueNodes) {

View File

@@ -5,7 +5,6 @@ import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
export class VueNodeHelpers {
/**
@@ -38,22 +37,6 @@ export class VueNodeHelpers {
return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper)
}
getInputSlotRow(nodeId: string, slotIndex: number): Locator {
return this.getNodeLocator(nodeId)
.locator('.lg-slot--input')
.filter({
has: this.page.locator(
`[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
)
})
}
getInputSlotConnectionDot(nodeId: string, slotIndex: number): Locator {
return this.getInputSlotRow(nodeId, slotIndex).getByTestId(
TestIds.node.slotConnectionDot
)
}
/**
* Get locator for Vue nodes by the node's title (displayed name in the header).
* Matches against the actual title element, not the full node body.

View File

@@ -1,34 +1,8 @@
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
const ASSETS_ROUTE_PATTERN = /\/api\/assets(?:\?.*)?$/
const cloudAssetRequestsByPage = new WeakMap<Page, string[]>()
function makeAssetsResponse(assets: ReadonlyArray<Asset>): ListAssetsResponse {
return { assets: [...assets], total: assets.length, has_more: false }
}
export function assetRequestIncludesTag(url: string, tag: string): boolean {
const includeTags = new URL(url).searchParams.get('include_tags') ?? ''
return includeTags
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.includes(tag)
}
export function countAssetRequestsByTag(
requests: string[],
tag: string
): number {
return requests.filter((url) => assetRequestIncludesTag(url, tag)).length
}
export const assetApiFixture = base.extend<{
assetApi: AssetHelper
}>({
@@ -40,31 +14,3 @@ export const assetApiFixture = base.extend<{
await assetApi.clearMocks()
}
})
export function createCloudAssetsFixture(assets: ReadonlyArray<Asset>) {
return comfyPageFixture.extend<{
cloudAssetRequests: string[]
}>({
page: async ({ page }, use) => {
const cloudAssetRequests: string[] = []
cloudAssetRequestsByPage.set(page, cloudAssetRequests)
async function assetsRouteHandler(route: Route) {
cloudAssetRequests.push(route.request().url())
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(assets))
})
}
await page.route(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
await use(page)
await page.unroute(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
cloudAssetRequestsByPage.delete(page)
},
cloudAssetRequests: async ({ page }, use) => {
await use(cloudAssetRequestsByPage.get(page) ?? [])
}
})
}

View File

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

View File

@@ -1,23 +1,16 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
export class Topbar {
private readonly menuLocator: Locator
private readonly menuTrigger: Locator
readonly newWorkflowButton: Locator
readonly workflowTabs: Locator
readonly integratedTabBarActions: Locator
constructor(public readonly page: Page) {
this.menuLocator = page.locator('.comfy-command-menu')
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
this.workflowTabs = page.getByTestId(TestIds.topbar.workflowTabs)
this.integratedTabBarActions = this.workflowTabs.getByTestId(
TestIds.topbar.integratedTabBarActions
)
}
async getTabNames(): Promise<string[]> {

View File

@@ -1,176 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
const defaultJobsListLimit = 100
export type MockJobRecord = {
listItem: JobEntry
detail: JobDetailResponse
}
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
const value = Number(url.searchParams.get(name))
return Number.isInteger(value) && value > 0 ? value : undefined
}
function getJobIdFromRequest(route: Route): string | null {
const url = new URL(route.request().url())
const jobId = url.pathname.split('/').at(-1)
return jobId ? decodeURIComponent(jobId) : null
}
export class JobsApiMock {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private jobsById = new Map<string, MockJobRecord>()
constructor(private readonly page: Page) {}
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
this.jobsById = new Map(
jobs.map(
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
)
)
await this.ensureRoutesRegistered()
}
async clear(): Promise<void> {
this.jobsById.clear()
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
let filteredJobs = Array.from(
this.jobsById.values(),
({ listItem }) => listItem
)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
const limit =
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
const total = filteredJobs.length
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = getJobIdFromRequest(route)
const job = jobId ? this.jobsById.get(jobId) : undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const requestBody = request.postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.jobsById = new Map(
Array.from(this.jobsById).filter(([, job]) => {
const status = job.listItem.status
return status === 'pending' || status === 'in_progress'
})
)
}
if (requestBody?.delete?.length) {
for (const jobId of requestBody.delete) {
this.jobsById.delete(jobId)
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -1,15 +0,0 @@
import { test as base } from '@playwright/test'
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
export const jobsApiMockFixture = base.extend<{
jobsApi: JobsApiMock
}>({
jobsApi: async ({ page }, use) => {
const jobsApi = new JobsApiMock(page)
await use(jobsApi)
await jobsApi.clear()
}
})

View File

@@ -91,8 +91,6 @@ export const TestIds = {
loginButton: 'login-button',
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
actionBarButtons: 'action-bar-buttons'
},
nodeLibrary: {
@@ -116,8 +114,7 @@ export const TestIds = {
titleInput: 'node-title-input',
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image',
slotConnectionDot: 'slot-connection-dot'
mainImage: 'main-image'
},
selectionToolbox: {
root: 'selection-toolbox',
@@ -214,7 +211,6 @@ export const TestIds = {
},
queue: {
overlayToggle: 'queue-overlay-toggle',
jobDetailsPopover: 'queue-job-details-popover',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list',
notificationBanner: 'queue-notification-banner'

View File

@@ -1,52 +0,0 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
function isTerminalStatus(status: JobEntry['status']) {
return status === 'completed' || status === 'failed' || status === 'cancelled'
}
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
const updateTime =
listItem.execution_end_time ??
listItem.execution_start_time ??
listItem.create_time
const detail: JobDetailResponse = {
...listItem,
update_time: updateTime,
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
}
return {
listItem,
detail
}
}
export function createMockJobRecords(
listItems: readonly JobEntry[]
): MockJobRecord[] {
return listItems.map(createMockJobRecord)
}

View File

@@ -1,20 +1,51 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
const test = createCloudAssetsFixture(CLOUD_ASSETS)
// Stub /api/assets before the app loads. The local ComfyUI backend has no
// /api/assets endpoint (returns 503), which poisons the assets store on
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
//
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
const test = comfyPageFixture.extend<{
cloudAssetRequests: string[]
stubCloudAssets: void
}>({
cloudAssetRequests: async ({ page: _page }, use) => {
await use([])
},
stubCloudAssets: [
async ({ cloudAssetRequests, page }, use) => {
const pattern = /\/api\/assets(?:\?.*)?$/
const assetsRouteHandler = (route: Route) => {
cloudAssetRequests.push(route.request().url())
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
})
}
await page.route(pattern, assetsRouteHandler)
await use()
await page.unroute(pattern, assetsRouteHandler)
},
{ auto: true }
]
})
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
test.afterEach(async ({ comfyPage }) => {
@@ -31,9 +62,11 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
// new nodes resolve against the cloud asset list after the fetch.
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'checkpoints')
)
cloudAssetRequests.some((url) => {
const includeTags =
new URL(url).searchParams.get('include_tags') ?? ''
return includeTags.split(',').includes('checkpoints')
})
)
.toBe(true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

@@ -1,127 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('Layout & sidebar settings', { tag: ['@settings'] }, () => {
test.describe('Comfy.Sidebar.Size', () => {
test('"small" applies small-sidebar class', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
await expect(comfyPage.menu.sideToolbar).toContainClass('small-sidebar')
})
test('"normal" does not apply small-sidebar class', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'small-sidebar'
)
})
})
test.describe('Comfy.Sidebar.Style', () => {
// `isConnected` overrides the Style setting when the toolbar overflows;
// small (48px) items keep content under the default viewport so Style
// actually drives rendering.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
})
test('"connected" applies connected-sidebar class', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'connected')
await expect(comfyPage.menu.sideToolbar).toContainClass(
'connected-sidebar'
)
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'floating-sidebar'
)
})
test('"floating" applies floating-sidebar class', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
await expect(comfyPage.menu.sideToolbar).toContainClass(
'floating-sidebar'
)
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'connected-sidebar'
)
})
test('"floating" + Size "normal" is overridden to connected by overflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
await expect(comfyPage.menu.sideToolbar).toContainClass(
'connected-sidebar'
)
await expect(comfyPage.menu.sideToolbar).toContainClass(
'overflowing-sidebar'
)
})
test('"floating" + Size "normal" renders floating in a viewport tall enough to fit', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
await comfyPage.page.setViewportSize({ width: 1280, height: 1500 })
await expect(comfyPage.menu.sideToolbar).toContainClass(
'floating-sidebar'
)
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
'overflowing-sidebar'
)
})
})
test.describe('Comfy.UI.TabBarLayout', () => {
test('"Default" renders integrated tab bar actions container', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Default')
await expect(comfyPage.menu.topbar.integratedTabBarActions).toBeAttached()
})
test('"Legacy" does not render integrated tab bar actions container', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Legacy')
await expect(comfyPage.menu.topbar.integratedTabBarActions).toHaveCount(0)
})
})
test.describe('Comfy.TreeExplorer.ItemPadding', () => {
// The setting writes a CSS var consumed by .p-tree-node-content,
// which only renders in the legacy PrimeVue Tree.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.menu.nodeLibraryTab.open()
})
test('low padding (0px) is applied to tree node content', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.TreeExplorer.ItemPadding', 0)
await expect(
comfyPage.menu.nodeLibraryTab.nodeLibraryTree
.locator('.p-tree-node-content')
.first()
).toHaveCSS('padding', '0px')
})
test('high padding (8px) is applied to tree node content', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.TreeExplorer.ItemPadding', 8)
await expect(
comfyPage.menu.nodeLibraryTab.nodeLibraryTree
.locator('.p-tree-node-content')
.first()
).toHaveCSS('padding', '8px')
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
import { expect } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
const WORKFLOW = 'missing/nested_subgraph_installed_model'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'diffusion_models'],
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: LOTUS_MODEL_NAME
}
}
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
test.describe(
'Errors tab - Cloud missing models',
{ tag: ['@cloud', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('keeps installed models resolved after returning from a nested subgraph', async ({
cloudAssetRequests,
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const panel = new PropertiesPanelHelper(comfyPage.page)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
const errorsTab = panel.root.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await expect
.poll(
() => countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models'),
{ timeout: 10_000 }
)
.toBeGreaterThan(0)
await expect(errorOverlay).toBeHidden()
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(errorsTab).toBeHidden()
await panel.close()
await comfyPage.vueNodes.enterSubgraph(OUTER_SUBGRAPH_NODE_ID)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(errorOverlay).toBeHidden()
const requestCountBeforeRootReturn = countAssetRequestsByTag(
cloudAssetRequests,
'diffusion_models'
)
await comfyPage.subgraph.exitViaBreadcrumb()
await panel.open(comfyPage.actionbar.propertiesButton)
await expect
.poll(
() =>
countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models') >
requestCountBeforeRootReturn,
{ timeout: 10_000 }
)
.toBe(true)
await expect(errorsTab).toBeHidden()
})
}
)

View File

@@ -1,19 +1,13 @@
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { expect } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/utils/jobFixtures'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const now = Date.now()
const MOCK_JOBS: JobEntry[] = [
const MOCK_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-completed-1',
status: 'completed',
@@ -37,25 +31,20 @@ const MOCK_JOBS: JobEntry[] = [
execution_start_time: now - 30_000,
execution_end_time: now - 28_000,
outputs_count: 0
}),
createMockJob({
id: 'job-failed-bottom',
status: 'failed',
create_time: now - 180_000,
execution_start_time: now - 180_000,
execution_end_time: now - 178_000,
outputs_count: 0
})
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
@@ -117,64 +106,4 @@ test.describe('Queue overlay', () => {
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeHidden()
})
test('Job details popover stays inside the viewport for bottom rows', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
const bottomJob = comfyPage.page.locator(
'[data-job-id="job-failed-bottom"]'
)
await expect(bottomJob).toBeVisible()
await bottomJob.scrollIntoViewIfNeeded()
await expect(bottomJob).toBeVisible()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) throw new Error('Viewport must be available')
const rowBox = await bottomJob.boundingBox()
if (!rowBox) throw new Error('Bottom job row should be measurable')
expect(
rowBox.y + rowBox.height,
'Test row should be low enough to exercise bottom-edge collision handling'
).toBeGreaterThan(viewportSize.height * 0.55)
await expect
.poll(async () =>
bottomJob.evaluate((element) => {
const rect = element.getBoundingClientRect()
const hitTarget = document.elementFromPoint(
rect.x + rect.width / 2,
rect.y + rect.height / 2
)
return hitTarget ? element.contains(hitTarget) : false
})
)
.toBe(true)
await comfyPage.page.mouse.move(0, 0)
await comfyPage.page.mouse.move(
rowBox.x + rowBox.width / 2,
rowBox.y + rowBox.height / 2,
{ steps: 5 }
)
const popover = comfyPage.page.getByTestId(TestIds.queue.jobDetailsPopover)
await expect(popover).toBeVisible()
await expect
.poll(async () => {
const popoverBox = await popover.boundingBox()
if (!popoverBox) return false
return (
popoverBox.y >= 0 &&
popoverBox.y + popoverBox.height <= viewportSize.height
)
})
.toBe(true)
})
})

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -535,28 +535,6 @@ test.describe(
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.toBeLessThan(initialWidgetCount)
})
test('Does not cleanup unconfigured Primitive', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'
)
await expect
.poll(
() => getPromotedWidgetCount(comfyPage, '2'),
'Primitive widget is restored on load'
)
.toBe(2)
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
const subgraphNode = await comfyPage.nodeOps.getFirstNodeRef()
const promotedPrimitive = await subgraphNode!.getWidget(1)
await expect
.poll(
() => promotedPrimitive.getValue(),
'Primitive widget is not in a disconnected state'
)
.toBe(0)
})
})
}
)

View File

@@ -125,48 +125,6 @@ test.describe('Workflow tabs', () => {
await expect(activeTab.locator('text=•')).toBeVisible()
})
test('Can drag tab to end', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
const [a, b, c] = await topbar.getTabNames()
await topbar.getTab(0).dragTo(topbar.getTab(2))
await expect.poll(() => topbar.getTabNames()).toEqual([b, c, a])
})
test('Can drag tab to start', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
const [a, b, c] = await topbar.getTabNames()
await topbar.getTab(2).dragTo(topbar.getTab(0))
await expect.poll(() => topbar.getTabNames()).toEqual([c, a, b])
})
test('Drag preserves active tab', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
const [, b] = await topbar.getTabNames()
await topbar.getTab(1).click()
await expect.poll(() => topbar.getActiveTabName()).toContain(b)
await topbar.getTab(0).dragTo(topbar.getTab(2))
await expect.poll(() => topbar.getActiveTabName()).toContain(b)
})
test('Multiple tabs can be created, switched, and closed', async ({
comfyPage
}) => {

View File

@@ -13,7 +13,6 @@ import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -21,7 +20,6 @@ const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
@@ -73,59 +71,6 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
).toHaveClass(ERROR_CLASS)
})
test(
'highlights the missing required input slot',
{ tag: ['@screenshot', '@node'] },
async ({ comfyPage }) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
const modelInputIndex = await comfyPage.page.evaluate(
({ nodeId, inputName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const index =
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ nodeId: ksamplerId, inputName: KSAMPLER_MODEL_INPUT_NAME }
)
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
ksamplerId,
modelInputIndex
)
const modelInputSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
ksamplerId,
modelInputIndex
)
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'required_input_missing',
KSAMPLER_MODEL_INPUT_NAME,
`Required input is missing: ${KSAMPLER_MODEL_INPUT_NAME}`
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await fitToViewInstant(comfyPage)
await expect(modelInputSlotRow).toBeVisible()
await expect(modelInputSlotRow).toBeInViewport()
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
await comfyPage.expectScreenshot(
ksamplerNode,
'vue-node-required-input-missing-slot-error.png'
)
}
)
test('clears error ring when user edits an out-of-range number widget back into range', async ({
comfyPage
}) => {

View File

@@ -36,7 +36,6 @@ const settings = {
alwaysTryTypes: true,
project: [
'./tsconfig.json',
'./browser_tests/tsconfig.json',
'./apps/*/tsconfig.json',
'./packages/*/tsconfig.json'
],

View File

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

View File

@@ -2524,46 +2524,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/luma_2/generations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Create a Luma Agents generation
* @description Submit an image generation or edit job. Returns immediately with an opaque job ID to poll via GET /proxy/luma_2/generations/{id}.
*/
post: operations["lumaAgentsCreateGeneration"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/luma_2/generations/{generation_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get a Luma Agents generation
* @description Poll for generation status and output. On completion, the response includes presigned URLs to download the generated images.
*/
get: operations["lumaAgentsGetGeneration"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/pixverse/video/text/generate": {
parameters: {
query?: never;
@@ -10421,88 +10381,6 @@ export interface components {
/** @description The request of the generation */
request?: components["schemas"]["LumaGenerationRequest"] | components["schemas"]["LumaImageGenerationRequest"] | components["schemas"]["LumaUpscaleVideoGenerationRequest"] | components["schemas"]["LumaAudioGenerationRequest"];
};
/**
* @description Output aspect ratio
* @enum {string}
*/
LumaAgentsAspectRatio: "3:1" | "2:1" | "16:9" | "3:2" | "1:1" | "2:3" | "9:16" | "1:2" | "1:3";
/**
* @description Style preset
* @enum {string}
*/
LumaAgentsStyle: "auto" | "manga";
/**
* @description Output image format
* @enum {string}
*/
LumaAgentsOutputFormat: "png" | "jpeg";
/**
* @description The kind of generation to perform
* @enum {string}
*/
LumaAgentsGenerationType: "image" | "image_edit";
/**
* @description Current state of the generation
* @enum {string}
*/
LumaAgentsState: "queued" | "processing" | "completed" | "failed";
/**
* @description Machine-readable failure code for programmatic handling
* @enum {string}
*/
LumaAgentsFailureCode: "content_moderated" | "generation_failed" | "budget_exhausted" | "output_not_found";
/** @description Reference image for style/content guidance or guided generation */
LumaAgentsImageRef: {
/** @description Base64-encoded image data */
data?: string;
/** @description MIME type (e.g. image/jpeg). Required with data. */
media_type?: string;
/** @description Publicly accessible image URL */
url?: string;
};
/** @description The Luma Agents generation request object */
LumaAgentsGenerationRequest: {
/** @description Text prompt */
prompt: string;
aspect_ratio?: components["schemas"]["LumaAgentsAspectRatio"];
/** @description Reference images for style/content guidance. Up to 9 for type 'image', up to 8 for type 'image_edit'. */
image_ref?: components["schemas"]["LumaAgentsImageRef"][];
/** @description Model to use */
model?: string;
output_format?: components["schemas"]["LumaAgentsOutputFormat"];
source?: components["schemas"]["LumaAgentsImageRef"];
style?: components["schemas"]["LumaAgentsStyle"];
type?: components["schemas"]["LumaAgentsGenerationType"];
/** @description Enable web search grounding */
web_search?: boolean;
};
/** @description A generated output entry */
LumaAgentsGenerationOutput: {
/** @description Media type (e.g. image) */
type?: string;
/** @description Presigned URL (1hr expiry) */
url?: string;
};
/** @description Generation status and output */
LumaAgentsGeneration: {
/** @description Generation identifier */
id?: string;
/** @description Creation timestamp */
created_at?: string;
/** @description Model used */
model?: string;
state?: components["schemas"]["LumaAgentsState"];
type?: components["schemas"]["LumaAgentsGenerationType"];
failure_code?: components["schemas"]["LumaAgentsFailureCode"];
/** @description Human-readable failure description */
failure_reason?: string;
output?: components["schemas"]["LumaAgentsGenerationOutput"][];
};
/** @description The error object */
LumaAgentsError: {
/** @description The error message */
detail?: string;
};
PixverseTextVideoRequest: {
/** @enum {string} */
aspect_ratio: "16:9" | "4:3" | "1:1" | "3:4" | "9:16";
@@ -12575,16 +12453,12 @@ export interface components {
Rodin3DGenerateRequest: {
/** @description The reference images to generate 3D Assets. */
images: string;
/** @description Text prompt used by the upstream Rodin API. Required by upstream for text-to-3D requests (no images uploaded); optional for image-to-3D requests where it acts as additional guidance. */
prompt?: string;
/** @description Seed. */
seed?: number;
tier?: components["schemas"]["RodinTierType"];
material?: components["schemas"]["RodinMaterialType"];
quality?: components["schemas"]["RodinQualityType"];
mesh_mode?: components["schemas"]["RodinMeshModeType"];
/** @description Optional list of upstream addon flags (e.g. "HighPack"). */
addons?: string[];
};
/**
* @description Rodin Tier para options
@@ -26309,72 +26183,6 @@ export interface operations {
};
};
};
lumaAgentsCreateGeneration: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description The generation request object */
requestBody: {
content: {
"application/json": components["schemas"]["LumaAgentsGenerationRequest"];
};
};
responses: {
/** @description Generation accepted */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsGeneration"];
};
};
/** @description Error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsError"];
};
};
};
};
lumaAgentsGetGeneration: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The ID of the generation */
generation_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Generation found */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsGeneration"];
};
};
/** @description Error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LumaAgentsError"];
};
};
};
};
PixverseGenerateTextVideo: {
parameters: {
query?: never;

28
pnpm-lock.yaml generated
View File

@@ -160,8 +160,8 @@ catalogs:
specifier: ^7.7.0
version: 7.7.0
'@types/three':
specifier: ^0.170.0
version: 0.170.0
specifier: ^0.169.0
version: 0.169.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
@@ -339,9 +339,6 @@ catalogs:
tailwindcss-primeui:
specifier: ^0.6.1
version: 0.6.1
three:
specifier: ^0.170.0
version: 0.170.0
tsx:
specifier: ^4.15.6
version: 4.19.4
@@ -701,7 +698,7 @@ importers:
version: 7.7.0
'@types/three':
specifier: 'catalog:'
version: 0.170.0
version: 0.169.0
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
@@ -967,9 +964,6 @@ importers:
posthog-js:
specifier: 'catalog:'
version: 1.358.1
three:
specifier: 'catalog:'
version: 0.170.0
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -4514,8 +4508,8 @@ packages:
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
'@types/three@0.170.0':
resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
'@types/three@0.169.0':
resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -9889,8 +9883,8 @@ packages:
vue-component-type-helpers@3.2.6:
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
vue-component-type-helpers@3.2.8:
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
vue-component-type-helpers@3.2.7:
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -13411,7 +13405,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.13(typescript@5.9.3)
vue-component-type-helpers: 3.2.8
vue-component-type-helpers: 3.2.7
'@swc/helpers@0.5.17':
dependencies:
@@ -13840,7 +13834,7 @@ snapshots:
'@types/stats.js@0.17.3': {}
'@types/three@0.170.0':
'@types/three@0.169.0':
dependencies:
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3
@@ -14195,7 +14189,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -20536,7 +20530,7 @@ snapshots:
vue-component-type-helpers@3.2.6: {}
vue-component-type-helpers@3.2.8: {}
vue-component-type-helpers@3.2.7: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:

View File

@@ -54,7 +54,7 @@ catalog:
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.170.0
'@types/three': ^0.169.0
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
@@ -113,7 +113,6 @@ catalog:
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.2.0
three: ^0.170.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8

View File

@@ -1,104 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import CustomizationDialog from './CustomizationDialog.vue'
const DEFAULT_ICON = 'pi-bookmark-fill'
const DEFAULT_COLOR = '#a1a1aa'
vi.mock('@/stores/nodeBookmarkStore', () => ({
useNodeBookmarkStore: () => ({
defaultBookmarkIcon: DEFAULT_ICON,
defaultBookmarkColor: DEFAULT_COLOR,
bookmarksCustomization: {}
})
}))
vi.mock('primevue/dialog', () => ({
default: {
name: 'Dialog',
template: '<div v-if="visible"><slot /><slot name="footer" /></div>',
props: ['visible']
}
}))
vi.mock('primevue/selectbutton', () => ({
default: {
name: 'SelectButton',
template: '<div />',
props: ['modelValue', 'options']
}
}))
vi.mock('primevue/divider', () => ({
default: { name: 'Divider', template: '<hr />' }
}))
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({
default: {
name: 'ColorCustomizationSelector',
template: '<div />',
props: ['modelValue', 'colorOptions']
}
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
name: 'Button',
template: `<button @click="$emit('click')"><slot /></button>`,
emits: ['click']
}
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function renderDialog(extraProps: Record<string, unknown> = {}) {
const onConfirm = vi.fn()
render(CustomizationDialog, {
global: { plugins: [i18n] },
props: { modelValue: true, onConfirm, ...extraProps }
})
return { onConfirm }
}
describe('CustomizationDialog', () => {
describe('confirmCustomization', () => {
it('emits confirm with default icon and color when no initial values provided', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog()
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR)
})
it('emits confirm with matching initialIcon when provided', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog({ initialIcon: 'pi-star' })
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith('pi-star', DEFAULT_COLOR)
})
it('falls back to default icon when initialIcon does not match any option', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog({ initialIcon: 'pi-nonexistent' })
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR)
})
it('emits confirm with initialColor when provided', async () => {
const user = userEvent.setup()
const { onConfirm } = renderDialog({ initialColor: '#007bff' })
await user.click(screen.getByText('g.confirm'))
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, '#007bff')
})
})
})

View File

@@ -94,15 +94,17 @@ const defaultIcon = iconOptions.find(
(option) => option.value === nodeBookmarkStore.defaultBookmarkIcon
)
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
// @ts-expect-error fixme ts strict error
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const resetCustomization = () => {
// @ts-expect-error fixme ts strict error
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ??
iconOptions[0]
iconOptions.find((option) => option.value === props.initialIcon) ||
defaultIcon
finalColor.value =
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@
<template v-if="showUI" #workflow-tabs>
<div
v-if="workflowTabsPosition === 'Topbar'"
data-testid="topbar-workflow-tabs"
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
>
<div

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import type * as RekaUi from 'reka-ui'
import './testUtils/mockTanstackVirtualizer'
@@ -28,85 +27,6 @@ vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: hoisted.jobDetailsPopoverStub
}))
vi.mock('reka-ui', async (importOriginal) => {
const actual = await importOriginal<typeof RekaUi>()
const { computed, defineComponent, h, inject, provide } = await import('vue')
const popoverOpenKey = Symbol('popoverOpen')
return {
...actual,
PopoverContent: defineComponent({
name: 'PopoverContent',
props: {
align: { type: String, default: undefined },
avoidCollisions: { type: Boolean, default: undefined },
collisionPadding: { type: Number, default: undefined },
hideWhenDetached: { type: Boolean, default: undefined },
positionStrategy: { type: String, default: undefined },
reference: { type: null, default: undefined },
side: { type: String, default: undefined },
sideFlip: { type: Boolean, default: undefined },
sideOffset: { type: Number, default: undefined },
sticky: { type: String, default: undefined }
},
emits: ['mouseenter', 'mouseleave'],
setup(props, { attrs, emit, slots }) {
const isOpen = inject(
popoverOpenKey,
computed(() => false)
)
return () =>
isOpen.value
? h(
'div',
{
class: attrs.class,
'data-align': props.align,
'data-avoid-collisions': props.avoidCollisions,
'data-collision-padding': props.collisionPadding,
'data-hide-when-detached': props.hideWhenDetached,
'data-position-strategy': props.positionStrategy,
'data-reference-bound': props.reference ? 'true' : 'false',
'data-side': props.side,
'data-side-flip': props.sideFlip,
'data-side-offset': props.sideOffset,
'data-sticky': props.sticky,
onMouseenter: () => emit('mouseenter'),
onMouseleave: () => emit('mouseleave')
},
slots.default?.()
)
: null
}
}),
PopoverPortal: {
name: 'PopoverPortal',
template: '<div><slot /></div>'
},
PopoverRoot: defineComponent({
name: 'PopoverRoot',
props: {
open: { type: Boolean, default: false }
},
setup(props, { slots }) {
provide(
popoverOpenKey,
computed(() => props.open)
)
return () =>
h(
'div',
{
'data-open': props.open
},
slots.default?.()
)
}
})
}
})
const AssetsListItemStub = defineComponent({
name: 'AssetsListItem',
props: {
@@ -152,7 +72,6 @@ vi.mock('vue-i18n', () => {
type TestPreviewOutput = {
url: string
previewUrl: string
isImage: boolean
isVideo: boolean
}
@@ -177,7 +96,6 @@ const createPreviewOutput = (
const url = `/api/view/${filename}`
return {
url,
previewUrl: mediaType === 'images' ? `${url}?res=512` : url,
isImage: mediaType === 'images',
isVideo: mediaType === 'video'
}
@@ -235,6 +153,30 @@ function renderJobAssetsList({
return { ...result, user }
}
function createDomRect({
top,
left,
width,
height
}: {
top: number
left: number
width: number
height: number
}): DOMRect {
return {
x: left,
y: top,
top,
left,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ''
} as DOMRect
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
@@ -298,18 +240,6 @@ describe('JobAssetsList', () => {
expect(onViewItem).toHaveBeenCalledWith(job)
})
it('uses thumbnail preview URLs for completed image rows', () => {
const preview = createPreviewOutput('job-1.png')
const job = buildJob({
taskRef: createTaskRef(preview)
})
const { container } = renderJobAssetsList({ jobs: [job] })
const stubRoot = container.querySelector('.assets-list-item-stub')!
expect(stubRoot.getAttribute('data-preview-url')).toBe(preview.previewUrl)
expect(stubRoot.getAttribute('data-preview-url')).not.toBe(preview.url)
})
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const onViewItem = vi.fn()
@@ -448,24 +378,54 @@ describe('JobAssetsList', () => {
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
})
it('anchors the popover to the active row through Reka', async () => {
it('positions the popover to the right of rows near the left viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
createDomRect({
top: 100,
left: 40,
width: 200,
height: 48
})
)
await fireEvent.mouseEnter(jobRow)
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = container.querySelector('.job-details-popover')!
expect(popover.getAttribute('data-avoid-collisions')).toBe('true')
expect(popover.getAttribute('data-hide-when-detached')).toBe('true')
expect(popover.getAttribute('data-reference-bound')).toBe('true')
expect(popover.getAttribute('data-side')).toBe('right')
expect(popover.getAttribute('data-side-flip')).toBe('true')
expect(popover.getAttribute('data-position-strategy')).toBe('fixed')
expect(popover.getAttribute('style')).toContain('left: 248px;')
})
it('positions the popover to the left of rows near the right viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
createDomRect({
top: 100,
left: 980,
width: 200,
height: 48
})
)
await fireEvent.mouseEnter(jobRow)
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = container.querySelector('.job-details-popover')!
expect(popover.getAttribute('style')).toContain('left: 672px;')
})
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
@@ -498,7 +458,7 @@ describe('JobAssetsList', () => {
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
})
it('updates the visible popover without closing when hovering another row', async () => {
it('shows the new popover after the previous row hides while the next row stays hovered', async () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
@@ -519,21 +479,17 @@ describe('JobAssetsList', () => {
await fireEvent.mouseLeave(firstRow)
await fireEvent.mouseEnter(secondRow)
await vi.advanceTimersByTimeAsync(150)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
await vi.advanceTimersByTimeAsync(50)
await nextTick()
expect(
container
.querySelector('.job-details-popover-stub')
?.getAttribute('data-job-id')
).toBe('job-2')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(
container
.querySelector('.job-details-popover-stub')
?.getAttribute('data-job-id')
).toBe('job-2')
const popoverStub = container.querySelector('.job-details-popover-stub')!
expect(popoverStub).not.toBeNull()
expect(popoverStub.getAttribute('data-job-id')).toBe('job-2')
})
it('does not show details if the hovered row disappears before the show delay ends', async () => {

View File

@@ -90,15 +90,23 @@
</div>
</div>
<JobDetailsHoverPopover
:open="isDetailsOpen && !!activeDetails && !!activeRowElement"
:job-id="activeDetails?.jobId"
:workflow-id="activeDetails?.workflowId"
:reference-element="activeRowElement"
@content-enter="onPopoverEnter"
@content-leave="onPopoverLeave"
@update:open="onPopoverOpenChange"
/>
<Teleport to="body">
<div
v-if="activeDetails && popoverPosition"
class="job-details-popover fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
>
<JobDetailsPopover
:job-id="activeDetails.jobId"
:workflow-id="activeDetails.workflowId"
/>
</div>
</Teleport>
</template>
<script setup lang="ts">
@@ -106,11 +114,13 @@ import type { VirtualItem } from '@tanstack/vue-virtual'
import type { CSSProperties } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useI18n } from 'vue-i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, ref } from 'vue'
import JobDetailsHoverPopover from '@/components/queue/job/JobDetailsHoverPopover.vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
import Button from '@/components/ui/button/Button.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { cn } from '@comfyorg/tailwind-utils'
import { iconForJobState } from '@/utils/queueDisplay'
@@ -122,8 +132,6 @@ import type { VirtualJobRow } from './buildVirtualJobRows'
const HEADER_ROW_HEIGHT = 20
const GROUP_ROW_GAP = 16
const JOB_ROW_HEIGHT = 48
const DETAILS_SHOW_DELAY_MS = 200
const DETAILS_HIDE_DELAY_MS = 150
defineOptions({
inheritAttrs: false
@@ -142,11 +150,7 @@ const { t } = useI18n()
const scrollContainer = ref<HTMLElement | null>(null)
const hoveredJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const activeDetails = ref<{ jobId: string; workflowId?: string } | null>(null)
const isDetailsOpen = ref(false)
const hideTimer = ref<number | null>(null)
const hideTimerJobId = ref<string | null>(null)
const showTimer = ref<number | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
const virtualizer = useVirtualizer({
get count(): number {
@@ -180,6 +184,18 @@ const virtualWrapperStyle = computed<CSSProperties>(() => ({
height: `${virtualizer.value.getTotalSize()}px`
})
}))
const {
activeDetails,
clearHoverTimers,
resetActiveDetails,
scheduleDetailsHide,
scheduleDetailsShow
} = useJobDetailsHover<{ jobId: string; workflowId?: string }>({
getActiveId: (details) => details.jobId,
getDisplayedJobGroups: () => displayedJobGroups,
onReset: clearPopoverAnchor
})
function getVirtualRowStyle(virtualItem: VirtualItem): CSSProperties {
return {
position: 'absolute',
@@ -213,88 +229,22 @@ function onListScroll() {
function clearPopoverAnchor() {
activeRowElement.value = null
popoverPosition.value = null
}
function clearHideTimer() {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
hideTimerJobId.value = null
}
function updatePopoverPosition() {
const rowElement = activeRowElement.value
if (!rowElement) return
function clearShowTimer() {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
function clearHoverTimers() {
clearHideTimer()
clearShowTimer()
}
function resetActiveDetails() {
clearHoverTimers()
isDetailsOpen.value = false
activeDetails.value = null
clearPopoverAnchor()
}
function hasDisplayedJob(jobId: string) {
return displayedJobGroups.some((group) =>
group.items.some((item) => item.id === jobId)
)
}
function scheduleDetailsShow(nextActive: {
jobId: string
workflowId?: string
}) {
clearShowTimer()
showTimer.value = window.setTimeout(() => {
showTimer.value = null
if (!hasDisplayedJob(nextActive.jobId)) return
activeDetails.value = nextActive
isDetailsOpen.value = true
}, DETAILS_SHOW_DELAY_MS)
}
function showDetailsNow(nextActive: { jobId: string; workflowId?: string }) {
clearHoverTimers()
if (!hasDisplayedJob(nextActive.jobId)) return
activeDetails.value = nextActive
isDetailsOpen.value = true
}
function scheduleDetailsHide(jobId?: string) {
if (!jobId) return
clearShowTimer()
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
return
}
clearHideTimer()
hideTimerJobId.value = jobId
hideTimer.value = window.setTimeout(() => {
const currentActive = activeDetails.value
if (currentActive?.jobId === jobId) {
isDetailsOpen.value = false
}
hideTimer.value = null
hideTimerJobId.value = null
}, DETAILS_HIDE_DELAY_MS)
const rect = rowElement.getBoundingClientRect()
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
scheduleDetailsHide(jobId)
scheduleDetailsHide(jobId, clearPopoverAnchor)
}
function onJobEnter(job: JobListItem, event: MouseEvent) {
@@ -304,22 +254,22 @@ function onJobEnter(job: JobListItem, event: MouseEvent) {
if (!(rowElement instanceof HTMLElement)) return
activeRowElement.value = rowElement
const nextActive = {
jobId: job.id,
workflowId: job.taskRef?.workflowId
}
if (isDetailsOpen.value && activeDetails.value?.jobId === job.id) {
if (activeDetails.value?.jobId === job.id) {
clearHoverTimers()
void nextTick(updatePopoverPosition)
return
}
const isSwitchingVisibleDetails = isDetailsOpen.value
const showDetails = isSwitchingVisibleDetails
? showDetailsNow
: scheduleDetailsShow
showDetails(nextActive)
scheduleDetailsShow(
{
jobId: job.id,
workflowId: job.taskRef?.workflowId
},
() => {
activeRowElement.value = rowElement
void nextTick(updatePopoverPosition)
}
)
}
function isCancelable(job: JobListItem) {
@@ -337,7 +287,7 @@ function getPreviewOutput(job: JobListItem) {
function getJobPreviewUrl(job: JobListItem) {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
return preview.previewUrl
return preview.url
}
return job.iconImageUrl
}
@@ -377,13 +327,7 @@ function onPopoverEnter() {
}
function onPopoverLeave() {
scheduleDetailsHide(activeDetails.value?.jobId)
}
function onPopoverOpenChange(open: boolean) {
if (!open) {
resetActiveDetails()
}
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
}
function getJobIconClass(job: JobListItem): string | undefined {
@@ -393,18 +337,4 @@ function getJobIconClass(job: JobListItem): string | undefined {
}
return undefined
}
watch(
() => displayedJobGroups,
() => {
const currentActive = activeDetails.value
if (!currentActive) return
if (!hasDisplayedJob(currentActive.jobId)) {
resetActiveDetails()
}
}
)
onBeforeUnmount(resetActiveDetails)
</script>

View File

@@ -1,62 +0,0 @@
<template>
<Popover :open="isOpen" @update:open="onOpenChange">
<PopoverContent
v-if="hasReference"
:reference="referenceElement ?? undefined"
data-testid="queue-job-details-popover"
side="right"
align="start"
:side-offset="8"
:collision-padding="8"
:avoid-collisions="true"
:side-flip="true"
:hide-when-detached="true"
position-strategy="fixed"
sticky="always"
class="job-details-popover z-1700 max-h-(--reka-popover-content-available-height) w-auto overflow-y-auto border-0 bg-transparent p-0 shadow-none will-change-transform"
@mouseenter="$emit('content-enter')"
@mouseleave="$emit('content-leave')"
@open-auto-focus.prevent
@close-auto-focus.prevent
>
<JobDetailsPopover
v-if="jobId"
:job-id="jobId"
:workflow-id="workflowId"
/>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
const {
open,
jobId = null,
workflowId,
referenceElement = null
} = defineProps<{
open: boolean
jobId?: string | null
workflowId?: string
referenceElement?: HTMLElement | null
}>()
const emit = defineEmits<{
(e: 'content-enter'): void
(e: 'content-leave'): void
(e: 'update:open', open: boolean): void
}>()
const isOpen = computed(() => open && !!jobId)
const hasReference = computed(() => !!jobId && !!referenceElement)
function onOpenChange(nextOpen: boolean) {
emit('update:open', nextOpen)
}
</script>

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { getHoverPopoverPosition } from './getHoverPopoverPosition'
describe('getHoverPopoverPosition', () => {
it('places the popover to the right when space is available', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 40, right: 240 },
1280
)
expect(position).toEqual({ top: 100, left: 248 })
})
it('places the popover to the left when right space is insufficient', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 980, right: 1180 },
1280
)
expect(position).toEqual({ top: 100, left: 672 })
})
it('clamps the top to viewport padding when rect.top is near the top edge', () => {
const position = getHoverPopoverPosition(
{ top: 2, left: 40, right: 240 },
1280
)
expect(position).toEqual({ top: 8, left: 248 })
})
it('clamps left to viewport padding when fallback would go off-screen', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 100, right: 300 },
320
)
expect(position).toEqual({ top: 100, left: 8 })
})
it('prefers right when both sides have equal space', () => {
const position = getHoverPopoverPosition(
{ top: 200, left: 340, right: 640 },
1280
)
expect(position).toEqual({ top: 200, left: 648 })
})
it('falls back to left when right space is less than popover width', () => {
const position = getHoverPopoverPosition(
{ top: 100, left: 600, right: 1000 },
1280
)
expect(position).toEqual({ top: 100, left: 292 })
})
it('handles narrow viewport where popover barely fits', () => {
const position = getHoverPopoverPosition(
{ top: 50, left: 8, right: 100 },
316
)
expect(position).toEqual({ top: 50, left: 8 })
})
})

View File

@@ -0,0 +1,39 @@
const POPOVER_GAP = 8
const POPOVER_WIDTH = 300
const VIEWPORT_PADDING = 8
type AnchorRect = Pick<DOMRect, 'top' | 'left' | 'right'>
type HoverPopoverPosition = {
top: number
left: number
}
export function getHoverPopoverPosition(
rect: AnchorRect,
viewportWidth: number
): HoverPopoverPosition {
const availableLeft = rect.left - POPOVER_GAP
const availableRight = viewportWidth - rect.right - POPOVER_GAP
const preferredLeft = rect.right + POPOVER_GAP
const fallbackLeft = rect.left - POPOVER_WIDTH - POPOVER_GAP
const maxLeft = Math.max(
VIEWPORT_PADDING,
viewportWidth - POPOVER_WIDTH - VIEWPORT_PADDING
)
if (
availableRight >= POPOVER_WIDTH &&
(availableRight >= availableLeft || availableLeft < POPOVER_WIDTH)
) {
return {
top: Math.max(VIEWPORT_PADDING, rect.top),
left: Math.min(maxLeft, preferredLeft)
}
}
return {
top: Math.max(VIEWPORT_PADDING, rect.top),
left: Math.max(VIEWPORT_PADDING, Math.min(maxLeft, fallbackLeft))
}
}

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
@@ -21,19 +21,13 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
}
}))
const mockRect = (el: HTMLElement, width: number) => {
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
right: width,
bottom: 100,
width,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
} as DOMRect)
}
vi.mock('@vueuse/core', () => ({
useMouseInElement: () => ({
elementX: ref(50),
elementWidth: ref(100),
isOutside: ref(false)
})
}))
describe('CompareSliderThumbnail', () => {
const renderThumbnail = (props = {}) => {
@@ -80,44 +74,4 @@ describe('CompareSliderThumbnail', () => {
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('50%')
})
it('updates slider position on mousemove', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 200)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: 50 } })
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('25%')
})
it('clamps slider position to [0, 100] when pointer overshoots', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 200)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: -10 } })
let divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('0%')
await user.pointer({ target: container, coords: { clientX: 250 } })
divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('100%')
})
it('ignores mousemove when container has zero width', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 0)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: 50 } })
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('50%')
})
})

View File

@@ -9,11 +9,7 @@
: 'max-w-full max-h-64 object-contain'
"
/>
<div
data-testid="compare-slider-container"
class="absolute inset-0"
@mousemove="updateSliderPosition"
>
<div ref="containerRef" class="absolute inset-0">
<LazyImage
:src="overlayImageSrc"
:alt="alt"
@@ -38,7 +34,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMouseInElement } from '@vueuse/core'
import { ref, watch } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
@@ -60,20 +57,18 @@ const isVideoType =
false
const sliderPosition = ref(SLIDER_START_POSITION)
const containerRef = ref<HTMLElement | null>(null)
/**
* Update slider position from a local mousemove. Scoped to currentTarget so
* only the hovered card reads its rect — unlike useMouseInElement which
* attaches a global mousemove listener and fires for every mounted instance.
*/
function updateSliderPosition(event: MouseEvent) {
const el = event.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (rect.width === 0) return
// Clamp to [0, 100] — subpixel rounding or stale rects on hover-in can
// push the raw percentage slightly out of range, which would offset the
// divider past the container or invert the overlay's clipPath.
const raw = ((event.clientX - rect.left) / rect.width) * 100
sliderPosition.value = Math.max(0, Math.min(100, raw))
}
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
// Update slider position based on mouse position when hovered
watch(
[() => isHovered, elementX, elementWidth, isOutside],
([isHovered, x, width, outside]) => {
if (!isHovered) return
if (!outside) {
sliderPosition.value = (x / width) * 100
}
}
)
</script>

View File

@@ -81,7 +81,6 @@
</Button>
<div
v-if="isIntegratedTabBar"
data-testid="integrated-tab-bar-actions"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<Button

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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