Compare commits
41 Commits
bl/website
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77dd85364c | ||
|
|
bd0f5c5e98 | ||
|
|
37f0fbcbef | ||
|
|
6ef051f200 | ||
|
|
0788e71394 | ||
|
|
d3f802de10 | ||
|
|
d78c630d36 | ||
|
|
aa4343a98b | ||
|
|
270c7e34f4 | ||
|
|
e9c5172317 | ||
|
|
666684e6e6 | ||
|
|
4484b62854 | ||
|
|
d29169ff4e | ||
|
|
3e6f3444e5 | ||
|
|
e46667b33f | ||
|
|
d5121d3fed | ||
|
|
733917d5cf | ||
|
|
08967bc684 | ||
|
|
fb32b9a5c5 | ||
|
|
6474faaa17 | ||
|
|
da6a3e0722 | ||
|
|
eecbaa8f39 | ||
|
|
0e110bec0d | ||
|
|
32984459bf | ||
|
|
0307281ff2 | ||
|
|
21406dceb1 | ||
|
|
14320a131f | ||
|
|
a763c7132c | ||
|
|
3f223dbbb4 | ||
|
|
60f789d580 | ||
|
|
24fc11aa3e | ||
|
|
055486cac0 | ||
|
|
f6ddd26cef | ||
|
|
6822a6883d | ||
|
|
3637b61fcd | ||
|
|
d1df5fadf8 | ||
|
|
7d67fe364b | ||
|
|
7c2321cc23 | ||
|
|
a877ccde94 | ||
|
|
e3883f4a2c | ||
|
|
5e16802832 |
6
.github/workflows/ci-perf-report.yaml
vendored
@@ -54,10 +54,14 @@ 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
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
|
||||
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
|
||||
|
||||
- name: Upload perf metrics
|
||||
if: always()
|
||||
|
||||
@@ -4,6 +4,9 @@ name: 'CI: Vercel Website Preview'
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches-ignore:
|
||||
- 'core/**'
|
||||
- 'cloud/**'
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('Payment failed page @smoke', () => {
|
||||
test('shows failure heading and subtitle', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: /Payment was not completed/i,
|
||||
name: /Unable to complete payment/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',
|
||||
|
||||
@@ -126,6 +126,7 @@ test.describe('Overflow guards', { tag: '@visual' }, () => {
|
||||
const pages = [
|
||||
'/',
|
||||
'/cloud',
|
||||
'/cloud/enterprise',
|
||||
'/cloud/pricing',
|
||||
'/contact',
|
||||
'/download',
|
||||
|
||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 99 KiB |
@@ -27,6 +27,7 @@
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"three": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const photos = [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team0.webp',
|
||||
@@ -17,45 +15,34 @@ 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 loopedPhotos"
|
||||
v-for="(photo, i) in photos"
|
||||
:key="i"
|
||||
class="aspect-3/4 h-64 shrink-0 md:h-96"
|
||||
>
|
||||
|
||||
@@ -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-2 lg:flex"
|
||||
class="hidden shrink-0 items-center gap-1 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-7"
|
||||
class="bg-primary-comfy-yellow block size-6 shrink-0"
|
||||
aria-hidden="true"
|
||||
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<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 class="relative flex-1">
|
||||
<video
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq.webm"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="w-full"
|
||||
<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>
|
||||
|
||||
|
||||
@@ -77,7 +77,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.creator.cta',
|
||||
ctaHref: subscribeUrl('creator'),
|
||||
featureIntroKey: 'pricing.plan.creator.featureIntro',
|
||||
features: [{ text: 'pricing.plan.creator.feature1' }],
|
||||
features: [
|
||||
{ text: 'pricing.plan.creator.feature1' },
|
||||
{ text: 'pricing.plan.creator.feature2' }
|
||||
],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
@@ -90,7 +93,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.pro.cta',
|
||||
ctaHref: subscribeUrl('pro'),
|
||||
featureIntroKey: 'pricing.plan.pro.featureIntro',
|
||||
features: [{ text: 'pricing.plan.pro.feature1' }]
|
||||
features: [
|
||||
{ text: 'pricing.plan.pro.feature1' },
|
||||
{ text: 'pricing.plan.pro.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
|
||||
@@ -35,20 +35,20 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
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)]"
|
||||
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)]"
|
||||
>
|
||||
<!-- 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: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)]"
|
||||
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)]"
|
||||
>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
class="block size-full"
|
||||
class="block size-full overflow-visible"
|
||||
viewBox="0 0 1600 1046"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g clip-path="url(#enterpriseHeroClip)">
|
||||
<g>
|
||||
<rect width="1600" height="1046" fill="#211927" />
|
||||
<rect
|
||||
width="800"
|
||||
@@ -84,7 +84,7 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<!-- Exploding block cluster -->
|
||||
<g class="block-cluster">
|
||||
<g class="block-cluster" clip-path="url(#enterpriseHeroBlockClip)">
|
||||
<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="enterpriseHeroClip">
|
||||
<clipPath id="enterpriseHeroBlockClip">
|
||||
<rect width="1600" height="1046" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
@@ -8,7 +8,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-transparency-white-t4 p-4 text-center lg:px-20 lg:py-8">
|
||||
<section
|
||||
class="bg-transparency-white-t4 relative z-20 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"
|
||||
>
|
||||
|
||||
328
apps/website/src/composables/useHeroLogo.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1119,6 +1119,10 @@ 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': {
|
||||
@@ -1143,6 +1147,10 @@ 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': {
|
||||
@@ -1599,7 +1607,7 @@ const translations = {
|
||||
},
|
||||
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
|
||||
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'nav.blogs': { en: 'Blogs', 'zh-CN': '博客' },
|
||||
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
|
||||
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
|
||||
'nav.docs': { en: 'Docs', 'zh-CN': '文档' },
|
||||
@@ -3498,18 +3506,6 @@ 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': {
|
||||
@@ -3694,8 +3690,8 @@ const translations = {
|
||||
'zh-CN': '支付'
|
||||
},
|
||||
'payment.failed.title': {
|
||||
en: 'Payment was not completed',
|
||||
'zh-CN': '支付未完成'
|
||||
en: 'Unable to complete payment',
|
||||
'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.",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
}
|
||||
@@ -17,10 +18,13 @@ 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)
|
||||
@@ -62,6 +66,7 @@ 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>
|
||||
|
||||
|
||||
@@ -7,9 +7,14 @@ 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">
|
||||
<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']}
|
||||
>
|
||||
<HeroSection />
|
||||
<ReasonSection />
|
||||
<AIModelsSection />
|
||||
|
||||
@@ -7,9 +7,14 @@ 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">
|
||||
<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']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
<HeroSection client:load />
|
||||
<ReasonSection />
|
||||
|
||||
@@ -8,9 +8,14 @@ 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">
|
||||
<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']}
|
||||
>
|
||||
<HeroSection client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection client:load />
|
||||
|
||||
@@ -7,9 +7,14 @@ 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">
|
||||
<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']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<ReasonSection locale="zh-CN" />
|
||||
<AIModelsSection locale="zh-CN" />
|
||||
|
||||
@@ -7,9 +7,14 @@ 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">
|
||||
<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']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<ReasonSection locale="zh-CN" />
|
||||
|
||||
@@ -8,9 +8,14 @@ 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 的最强可控性">
|
||||
<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 工作流']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
1449
browser_tests/assets/subgraphs/large-subgraph-80-nodes.json
Normal file
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -352,6 +352,12 @@ 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)
|
||||
}
|
||||
@@ -460,10 +466,15 @@ 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)
|
||||
@@ -480,7 +491,7 @@ export const comfyPageFixture = base.extend<{
|
||||
await mcr.add(coverage)
|
||||
},
|
||||
|
||||
comfyPage: async ({ page, request }, use, testInfo) => {
|
||||
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
|
||||
const { parallelIndex } = testInfo
|
||||
@@ -524,6 +535,10 @@ 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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 {
|
||||
/**
|
||||
@@ -37,6 +38,22 @@ 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.
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
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
|
||||
}>({
|
||||
@@ -14,3 +40,31 @@ 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) ?? [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
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[]> {
|
||||
|
||||
176
browser_tests/fixtures/helpers/JobsApiMock.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
browser_tests/fixtures/jobsApiMockFixture.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -91,6 +91,8 @@ 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: {
|
||||
@@ -114,7 +116,8 @@ export const TestIds = {
|
||||
titleInput: 'node-title-input',
|
||||
pinIndicator: 'node-pin-indicator',
|
||||
innerWrapper: 'node-inner-wrapper',
|
||||
mainImage: 'main-image'
|
||||
mainImage: 'main-image',
|
||||
slotConnectionDot: 'slot-connection-dot'
|
||||
},
|
||||
selectionToolbox: {
|
||||
root: 'selection-toolbox',
|
||||
@@ -211,6 +214,7 @@ 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'
|
||||
|
||||
52
browser_tests/fixtures/utils/jobFixtures.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,51 +1,20 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
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'
|
||||
|
||||
// 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 }
|
||||
]
|
||||
})
|
||||
const test = createCloudAssetsFixture(CLOUD_ASSETS)
|
||||
|
||||
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
@@ -62,11 +31,9 @@ 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) => {
|
||||
const includeTags =
|
||||
new URL(url).searchParams.get('include_tags') ?? ''
|
||||
return includeTags.split(',').includes('checkpoints')
|
||||
})
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'checkpoints')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
548
browser_tests/tests/keybindingPanel.spec.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
127
browser_tests/tests/layoutSidebarSettings.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 53 KiB |
@@ -351,6 +351,45 @@ 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')
|
||||
|
||||
42
browser_tests/tests/previewAsText.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,13 +1,19 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
@@ -31,20 +37,25 @@ const MOCK_JOBS: RawJobListItem[] = [
|
||||
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 }) => {
|
||||
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
|
||||
test.beforeEach(async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
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()
|
||||
@@ -106,4 +117,64 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
83
browser_tests/tests/queueButtonModes.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Queue button modes', { tag: '@ui' }, () => {
|
||||
test('Run button is visible in topbar', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.runButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
|
||||
const trigger = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.queueModeMenuTrigger
|
||||
)
|
||||
await expect(trigger).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
|
||||
const trigger = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.queueModeMenuTrigger
|
||||
)
|
||||
await trigger.click()
|
||||
|
||||
const menu = comfyPage.page.getByRole('menu')
|
||||
await expect(menu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
|
||||
const trigger = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.queueModeMenuTrigger
|
||||
)
|
||||
await trigger.click()
|
||||
|
||||
const menu = comfyPage.page.getByRole('menu')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const items = menu.getByRole('menuitem')
|
||||
await expect(items).toHaveCount(3)
|
||||
await expect(items.nth(0)).toHaveText('Run')
|
||||
await expect(items.nth(1)).toHaveText('Run (On Change)')
|
||||
await expect(items.nth(2)).toHaveText('Run (Instant)')
|
||||
})
|
||||
|
||||
test('Selecting a non-default mode updates the Run button label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const trigger = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.queueModeMenuTrigger
|
||||
)
|
||||
await trigger.click()
|
||||
|
||||
const menu = comfyPage.page.getByRole('menu')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
// Select "Run (On Change)" — a non-default mode so we observe a real change
|
||||
const onChangeItem = menu.getByRole('menuitem').nth(1)
|
||||
await onChangeItem.click()
|
||||
|
||||
await expect(comfyPage.runButton).toContainText('Run (On Change)')
|
||||
})
|
||||
|
||||
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.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,7 @@ test.describe(
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('Save WEBM')
|
||||
|
||||
// Wait for SaveImage to render an img inside .image-preview
|
||||
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -535,6 +535,28 @@ 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -125,6 +125,48 @@ 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
|
||||
}) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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)
|
||||
@@ -20,6 +21,7 @@ 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 ({
|
||||
@@ -71,6 +73,59 @@ 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
|
||||
}) => {
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -36,6 +36,7 @@ const settings = {
|
||||
alwaysTryTypes: true,
|
||||
project: [
|
||||
'./tsconfig.json',
|
||||
'./browser_tests/tsconfig.json',
|
||||
'./apps/*/tsconfig.json',
|
||||
'./packages/*/tsconfig.json'
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.16",
|
||||
"version": "1.44.18",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
192
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -2524,6 +2524,46 @@ 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;
|
||||
@@ -10381,6 +10421,88 @@ 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";
|
||||
@@ -12453,12 +12575,16 @@ 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
|
||||
@@ -26183,6 +26309,72 @@ 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
@@ -160,8 +160,8 @@ catalogs:
|
||||
specifier: ^7.7.0
|
||||
version: 7.7.0
|
||||
'@types/three':
|
||||
specifier: ^0.169.0
|
||||
version: 0.169.0
|
||||
specifier: ^0.170.0
|
||||
version: 0.170.0
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
@@ -339,6 +339,9 @@ 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
|
||||
@@ -698,7 +701,7 @@ importers:
|
||||
version: 7.7.0
|
||||
'@types/three':
|
||||
specifier: 'catalog:'
|
||||
version: 0.169.0
|
||||
version: 0.170.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))
|
||||
@@ -964,6 +967,9 @@ 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)
|
||||
@@ -4508,8 +4514,8 @@ packages:
|
||||
'@types/stats.js@0.17.3':
|
||||
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
|
||||
|
||||
'@types/three@0.169.0':
|
||||
resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
|
||||
'@types/three@0.170.0':
|
||||
resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
|
||||
|
||||
'@types/tough-cookie@4.0.5':
|
||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||
@@ -9883,8 +9889,8 @@ packages:
|
||||
vue-component-type-helpers@3.2.6:
|
||||
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
|
||||
|
||||
vue-component-type-helpers@3.2.7:
|
||||
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
|
||||
vue-component-type-helpers@3.2.8:
|
||||
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -13405,7 +13411,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.7
|
||||
vue-component-type-helpers: 3.2.8
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -13834,7 +13840,7 @@ snapshots:
|
||||
|
||||
'@types/stats.js@0.17.3': {}
|
||||
|
||||
'@types/three@0.169.0':
|
||||
'@types/three@0.170.0':
|
||||
dependencies:
|
||||
'@tweenjs/tween.js': 23.1.3
|
||||
'@types/stats.js': 0.17.3
|
||||
@@ -14189,7 +14195,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@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: 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/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -20530,7 +20536,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.6: {}
|
||||
|
||||
vue-component-type-helpers@3.2.7: {}
|
||||
vue-component-type-helpers@3.2.8: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -54,7 +54,7 @@ catalog:
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@types/three': ^0.170.0
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
@@ -113,6 +113,7 @@ 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
|
||||
|
||||
104
src/components/common/CustomizationDialog.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -94,17 +94,15 @@ const defaultIcon = iconOptions.find(
|
||||
(option) => option.value === nodeBookmarkStore.defaultBookmarkIcon
|
||||
)
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
|
||||
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
|
||||
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) ||
|
||||
defaultIcon
|
||||
iconOptions.find((option) => option.value === props.initialIcon) ??
|
||||
iconOptions[0]
|
||||
finalColor.value =
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
}
|
||||
|
||||
192
src/components/dialog/GlobalDialog.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,49 +1,106 @@
|
||||
<!-- The main global dialog to show various things -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</Dialog>
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</PrimeDialog>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import PrimeDialog 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 } from '@/stores/dialogStore'
|
||||
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
@@ -53,6 +110,14 @@ 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
|
||||
|
||||
@@ -193,11 +193,12 @@
|
||||
</template>
|
||||
</Column>
|
||||
<template #expansion="slotProps">
|
||||
<div class="pl-4">
|
||||
<div class="pl-4" data-testid="keybinding-expansion-content">
|
||||
<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">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<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
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
|
||||
@@ -27,6 +28,85 @@ 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: {
|
||||
@@ -72,6 +152,7 @@ vi.mock('vue-i18n', () => {
|
||||
|
||||
type TestPreviewOutput = {
|
||||
url: string
|
||||
previewUrl: string
|
||||
isImage: boolean
|
||||
isVideo: boolean
|
||||
}
|
||||
@@ -96,6 +177,7 @@ const createPreviewOutput = (
|
||||
const url = `/api/view/${filename}`
|
||||
return {
|
||||
url,
|
||||
previewUrl: mediaType === 'images' ? `${url}?res=512` : url,
|
||||
isImage: mediaType === 'images',
|
||||
isVideo: mediaType === 'video'
|
||||
}
|
||||
@@ -153,30 +235,6 @@ 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()
|
||||
@@ -240,6 +298,18 @@ 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()
|
||||
@@ -378,54 +448,24 @@ describe('JobAssetsList', () => {
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
})
|
||||
|
||||
it('positions the popover to the right of rows near the left viewport edge', async () => {
|
||||
it('anchors the popover to the active row through Reka', 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('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;')
|
||||
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')
|
||||
})
|
||||
|
||||
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
|
||||
@@ -458,7 +498,7 @@ describe('JobAssetsList', () => {
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the new popover after the previous row hides while the next row stays hovered', async () => {
|
||||
it('updates the visible popover without closing when hovering another row', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
@@ -479,17 +519,21 @@ 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()
|
||||
|
||||
const popoverStub = container.querySelector('.job-details-popover-stub')!
|
||||
expect(popoverStub).not.toBeNull()
|
||||
expect(popoverStub.getAttribute('data-job-id')).toBe('job-2')
|
||||
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')
|
||||
})
|
||||
|
||||
it('does not show details if the hovered row disappears before the show delay ends', async () => {
|
||||
|
||||
@@ -90,23 +90,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -114,13 +106,11 @@ 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, nextTick, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
import JobDetailsHoverPopover from '@/components/queue/job/JobDetailsHoverPopover.vue'
|
||||
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'
|
||||
@@ -132,6 +122,8 @@ 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
|
||||
@@ -150,7 +142,11 @@ const { t } = useI18n()
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const activeRowElement = ref<HTMLElement | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const 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 flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
|
||||
const virtualizer = useVirtualizer({
|
||||
get count(): number {
|
||||
@@ -184,18 +180,6 @@ 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',
|
||||
@@ -229,22 +213,88 @@ function onListScroll() {
|
||||
|
||||
function clearPopoverAnchor() {
|
||||
activeRowElement.value = null
|
||||
popoverPosition.value = null
|
||||
}
|
||||
|
||||
function updatePopoverPosition() {
|
||||
const rowElement = activeRowElement.value
|
||||
if (!rowElement) return
|
||||
function clearHideTimer() {
|
||||
if (hideTimer.value !== null) {
|
||||
clearTimeout(hideTimer.value)
|
||||
hideTimer.value = null
|
||||
}
|
||||
hideTimerJobId.value = null
|
||||
}
|
||||
|
||||
const rect = rowElement.getBoundingClientRect()
|
||||
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
|
||||
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)
|
||||
}
|
||||
|
||||
function onJobLeave(jobId: string) {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
scheduleDetailsHide(jobId, clearPopoverAnchor)
|
||||
scheduleDetailsHide(jobId)
|
||||
}
|
||||
|
||||
function onJobEnter(job: JobListItem, event: MouseEvent) {
|
||||
@@ -254,22 +304,22 @@ function onJobEnter(job: JobListItem, event: MouseEvent) {
|
||||
if (!(rowElement instanceof HTMLElement)) return
|
||||
|
||||
activeRowElement.value = rowElement
|
||||
if (activeDetails.value?.jobId === job.id) {
|
||||
const nextActive = {
|
||||
jobId: job.id,
|
||||
workflowId: job.taskRef?.workflowId
|
||||
}
|
||||
|
||||
if (isDetailsOpen.value && activeDetails.value?.jobId === job.id) {
|
||||
clearHoverTimers()
|
||||
void nextTick(updatePopoverPosition)
|
||||
return
|
||||
}
|
||||
|
||||
scheduleDetailsShow(
|
||||
{
|
||||
jobId: job.id,
|
||||
workflowId: job.taskRef?.workflowId
|
||||
},
|
||||
() => {
|
||||
activeRowElement.value = rowElement
|
||||
void nextTick(updatePopoverPosition)
|
||||
}
|
||||
)
|
||||
const isSwitchingVisibleDetails = isDetailsOpen.value
|
||||
const showDetails = isSwitchingVisibleDetails
|
||||
? showDetailsNow
|
||||
: scheduleDetailsShow
|
||||
|
||||
showDetails(nextActive)
|
||||
}
|
||||
|
||||
function isCancelable(job: JobListItem) {
|
||||
@@ -287,7 +337,7 @@ function getPreviewOutput(job: JobListItem) {
|
||||
function getJobPreviewUrl(job: JobListItem) {
|
||||
const preview = getPreviewOutput(job)
|
||||
if (preview?.isImage || preview?.isVideo) {
|
||||
return preview.url
|
||||
return preview.previewUrl
|
||||
}
|
||||
return job.iconImageUrl
|
||||
}
|
||||
@@ -327,7 +377,13 @@ function onPopoverEnter() {
|
||||
}
|
||||
|
||||
function onPopoverLeave() {
|
||||
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
|
||||
scheduleDetailsHide(activeDetails.value?.jobId)
|
||||
}
|
||||
|
||||
function onPopoverOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
resetActiveDetails()
|
||||
}
|
||||
}
|
||||
|
||||
function getJobIconClass(job: JobListItem): string | undefined {
|
||||
@@ -337,4 +393,18 @@ 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>
|
||||
|
||||
62
src/components/queue/job/JobDetailsHoverPopover.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<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>
|
||||
@@ -1,61 +0,0 @@
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@
|
||||
</Button>
|
||||
<div
|
||||
v-if="isIntegratedTabBar"
|
||||
data-testid="integrated-tab-bar-actions"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<Button
|
||||
|
||||
188
src/components/ui/dialog/Dialog.stories.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
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>
|
||||
`
|
||||
})
|
||||
}
|
||||
13
src/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
18
src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<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>
|
||||
33
src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<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>
|
||||
20
src/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
22
src/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
22
src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
23
src/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<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>
|
||||
12
src/components/ui/dialog/DialogPortal.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
20
src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import { DialogTitle } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: customClass = '', ...delegated } = defineProps<
|
||||
DialogTitleProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
v-bind="delegated"
|
||||
:class="cn('text-base font-semibold text-base-foreground', customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
32
src/components/ui/dialog/dialog.variants.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const dialogContentVariants = cva({
|
||||
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-xl',
|
||||
lg: 'sm:max-w-3xl',
|
||||
xl: 'sm:max-w-5xl',
|
||||
full: 'sm:max-w-[calc(100vw-1rem)]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md'
|
||||
}
|
||||
})
|
||||
|
||||
export type DialogContentVariants = VariantProps<typeof dialogContentVariants>
|
||||
|
||||
export type DialogContentSize = NonNullable<DialogContentVariants['size']>
|
||||
|
||||
const sizes = [
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'xl',
|
||||
'full'
|
||||
] as const satisfies Array<DialogContentSize>
|
||||
|
||||
export const FOR_STORIES = { sizes } as const
|
||||
16
src/components/ui/popover/Popover.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
|
||||
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<PopoverRootProps>()
|
||||
const emits = defineEmits<PopoverRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
49
src/components/ui/popover/PopoverContent.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
|
||||
import { PopoverContent, PopoverPortal, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
align,
|
||||
sideOffset,
|
||||
...restProps
|
||||
}))
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 w-72 rounded-md border bg-base-background p-4 text-base-foreground shadow-md outline-none',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</template>
|
||||
@@ -20,27 +20,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
function seedSimpleError(
|
||||
store: ReturnType<typeof useExecutionErrorStore>,
|
||||
executionId: string,
|
||||
inputName: string
|
||||
) {
|
||||
store.lastNodeErrors = {
|
||||
[executionId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: inputName }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
}
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
|
||||
describe('Connection error clearing via onConnectionsChange', () => {
|
||||
beforeEach(() => {
|
||||
@@ -63,7 +43,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedSimpleError(store, String(node.id), 'clip')
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
@@ -75,7 +55,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedSimpleError(store, String(node.id), 'clip')
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
@@ -94,7 +74,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedSimpleError(store, String(node.id), 'clip')
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.OUTPUT,
|
||||
@@ -116,7 +96,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedSimpleError(store, String(node.id), 'model')
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
@@ -261,7 +241,11 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
|
||||
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
|
||||
// with that name so the slot-name filter matches.
|
||||
seedSimpleError(store, interiorExecId, promotedWidget!.name)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
interiorExecId,
|
||||
promotedWidget!.name
|
||||
)
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
@@ -300,7 +284,7 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
// Verify the hooks actually work
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedSimpleError(store, String(lateNode.id), 'value')
|
||||
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
|
||||
|
||||
lateNode.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
@@ -831,6 +815,58 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('skips nested subgraph containers during parent subgraph replay scan', async () => {
|
||||
const rootGraph = new LGraph()
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const leafNode = new LGraphNode('UNETLoader')
|
||||
innerSubgraph.add(leafNode)
|
||||
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: outerSubgraph,
|
||||
id: 76
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph,
|
||||
id: 205
|
||||
})
|
||||
rootGraph.add(outerSubgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const modelScanSpy = vi
|
||||
.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
.mockReturnValue([])
|
||||
const mediaScanSpy = vi
|
||||
.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
.mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(rootGraph)
|
||||
|
||||
rootGraph.onNodeAdded?.(outerSubgraphNode)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(modelScanSpy).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
leafNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(modelScanSpy).not.toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
innerSubgraphNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode, false)
|
||||
expect(mediaScanSpy).not.toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
innerSubgraphNode,
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
@@ -162,6 +162,7 @@ function scanAndAddNodeErrors(node: LGraphNode): void {
|
||||
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
for (const innerNode of collectAllNodes(node.subgraph)) {
|
||||
if (innerNode.isSubgraphNode?.()) continue
|
||||
if (isNodeInactive(innerNode.mode)) continue
|
||||
scanSingleNodeErrors(innerNode)
|
||||
}
|
||||
|
||||
@@ -233,6 +233,35 @@ describe('handleDrawing', () => {
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
rafSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sets DestinationOut composition when tool is eraser during move', async () => {
|
||||
mockStoreDef.currentTool = 'eraser'
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
const { startDrawing, handleDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await handleDrawing(makePointerEvent(55, 55))
|
||||
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe(
|
||||
'destination-out'
|
||||
)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sets DestinationOut composition when right mouse button held during move', async () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
const { startDrawing, handleDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await handleDrawing(makePointerEvent(55, 55, { buttons: 2 }))
|
||||
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe(
|
||||
'destination-out'
|
||||
)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drawEnd canvas visibility', () => {
|
||||
@@ -272,6 +301,36 @@ describe('drawEnd', () => {
|
||||
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(false, false)
|
||||
})
|
||||
|
||||
it('passes isRgb=true to compositeStroke when active layer is rgb', async () => {
|
||||
mockStoreDef.activeLayer = 'rgb'
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(true, false)
|
||||
})
|
||||
|
||||
it('passes isErasing=true to compositeStroke when tool is eraser', async () => {
|
||||
mockStoreDef.currentTool = 'eraser'
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(false, true)
|
||||
})
|
||||
|
||||
it('restores mask canvas opacity after drawing on mask layer', async () => {
|
||||
mockStoreDef.activeLayer = 'mask'
|
||||
const mockMaskCanvas = {
|
||||
width: 200,
|
||||
height: 200,
|
||||
style: { opacity: '' }
|
||||
} as unknown as HTMLCanvasElement
|
||||
mockStoreDef.maskCanvas = mockMaskCanvas
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(mockMaskCanvas.style.opacity).toBe(String(mockStoreDef.maskOpacity))
|
||||
})
|
||||
|
||||
it('calls clearPreview to clean up the GPU overlay', async () => {
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type { JobGroup } from '@/composables/queue/useJobList'
|
||||
|
||||
const DETAILS_SHOW_DELAY_MS = 200
|
||||
const DETAILS_HIDE_DELAY_MS = 150
|
||||
|
||||
interface UseJobDetailsHoverOptions<TActive> {
|
||||
getActiveId: (active: TActive) => string
|
||||
getDisplayedJobGroups: () => JobGroup[]
|
||||
onReset?: () => void
|
||||
}
|
||||
|
||||
export function useJobDetailsHover<TActive>({
|
||||
getActiveId,
|
||||
getDisplayedJobGroups,
|
||||
onReset
|
||||
}: UseJobDetailsHoverOptions<TActive>) {
|
||||
const activeDetails = ref<TActive | null>(null)
|
||||
const hideTimer = ref<number | null>(null)
|
||||
const hideTimerJobId = ref<string | null>(null)
|
||||
const showTimer = ref<number | null>(null)
|
||||
|
||||
function clearHideTimer() {
|
||||
if (hideTimer.value !== null) {
|
||||
clearTimeout(hideTimer.value)
|
||||
hideTimer.value = null
|
||||
}
|
||||
hideTimerJobId.value = null
|
||||
}
|
||||
|
||||
function clearShowTimer() {
|
||||
if (showTimer.value !== null) {
|
||||
clearTimeout(showTimer.value)
|
||||
showTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearHoverTimers() {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
}
|
||||
|
||||
function resetActiveDetails() {
|
||||
clearHoverTimers()
|
||||
activeDetails.value = null
|
||||
onReset?.()
|
||||
}
|
||||
|
||||
function hasDisplayedJob(jobId: string) {
|
||||
return getDisplayedJobGroups().some((group) =>
|
||||
group.items.some((item) => item.id === jobId)
|
||||
)
|
||||
}
|
||||
|
||||
function scheduleDetailsShow(nextActive: TActive, onShow?: () => void) {
|
||||
const nextActiveId = getActiveId(nextActive)
|
||||
clearShowTimer()
|
||||
showTimer.value = window.setTimeout(() => {
|
||||
showTimer.value = null
|
||||
if (!hasDisplayedJob(nextActiveId)) return
|
||||
|
||||
activeDetails.value = nextActive
|
||||
onShow?.()
|
||||
}, DETAILS_SHOW_DELAY_MS)
|
||||
}
|
||||
|
||||
function scheduleDetailsHide(jobId?: string, onHide?: () => void) {
|
||||
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 && getActiveId(currentActive) === jobId) {
|
||||
activeDetails.value = null
|
||||
onHide?.()
|
||||
}
|
||||
hideTimer.value = null
|
||||
hideTimerJobId.value = null
|
||||
}, DETAILS_HIDE_DELAY_MS)
|
||||
}
|
||||
|
||||
watch(getDisplayedJobGroups, () => {
|
||||
const currentActive = activeDetails.value
|
||||
if (!currentActive) return
|
||||
|
||||
if (!hasDisplayedJob(getActiveId(currentActive))) {
|
||||
resetActiveDetails()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(resetActiveDetails)
|
||||
|
||||
return {
|
||||
activeDetails,
|
||||
clearHoverTimers,
|
||||
resetActiveDetails,
|
||||
scheduleDetailsHide,
|
||||
scheduleDetailsShow
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,11 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
|
||||
isAssetPreviewSupported: vi.fn(() => false),
|
||||
persistThumbnail: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
describe('useLoad3d', () => {
|
||||
let mockLoad3d: Partial<Load3d>
|
||||
let mockNode: LGraphNode
|
||||
@@ -181,6 +186,12 @@ describe('useLoad3d', () => {
|
||||
resetGizmoTransform: vi.fn(),
|
||||
applyGizmoTransform: vi.fn(),
|
||||
fitToViewer: vi.fn(),
|
||||
getGizmoTransform: vi.fn().mockReturnValue({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}),
|
||||
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
|
||||
setAnimationTime: vi.fn(),
|
||||
renderer: {
|
||||
domElement: mockCanvas
|
||||
@@ -832,6 +843,7 @@ describe('useLoad3d', () => {
|
||||
'backgroundImageLoadingEnd',
|
||||
'modelLoadingStart',
|
||||
'modelLoadingEnd',
|
||||
'modelReady',
|
||||
'skeletonVisibilityChange',
|
||||
'exportLoadingStart',
|
||||
'exportLoadingEnd',
|
||||
@@ -1382,4 +1394,169 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.resetGizmoTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleFitToViewer', () => {
|
||||
it('persists post-fit position and scale into modelConfig.gizmo so reload reapplies the transform via applyGizmoConfigToLoad3d', async () => {
|
||||
const fitTransform = {
|
||||
position: { x: 0, y: -1.25, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 0.42, y: 0.42, z: 0.42 }
|
||||
}
|
||||
vi.mocked(mockLoad3d.getGizmoTransform!).mockReturnValue(fitTransform)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleFitToViewer()
|
||||
|
||||
expect(mockLoad3d.fitToViewer).toHaveBeenCalledOnce()
|
||||
expect(composable.modelConfig.value.gizmo!.position).toEqual(
|
||||
fitTransform.position
|
||||
)
|
||||
expect(composable.modelConfig.value.gizmo!.scale).toEqual(
|
||||
fitTransform.scale
|
||||
)
|
||||
// Rotation is owned by upDirection — fit must not overwrite it.
|
||||
expect(composable.modelConfig.value.gizmo!.rotation).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op when load3d is not initialized', () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
// No initializeLoad3d() call.
|
||||
composable.handleFitToViewer()
|
||||
expect(mockLoad3d.fitToViewer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not throw when modelConfig.gizmo is missing', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
composable.modelConfig.value.gizmo = undefined
|
||||
|
||||
expect(() => composable.handleFitToViewer()).not.toThrow()
|
||||
expect(mockLoad3d.fitToViewer).toHaveBeenCalledOnce()
|
||||
// Without a gizmo slot we silently skip persistence — getGizmoTransform
|
||||
// is not called because the early-return saves the read.
|
||||
expect(mockLoad3d.getGizmoTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('modelReady event handler (thumbnail capture)', () => {
|
||||
let originalFetch: typeof globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
blob: () => Promise.resolve(new Blob(['x'], { type: 'image/png' }))
|
||||
} as unknown as Response)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
async function getModelReadyHandler() {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
const call = vi
|
||||
.mocked(mockLoad3d.addEventListener!)
|
||||
.mock.calls.find(([event]) => event === 'modelReady')
|
||||
return { composable, handler: call![1] as () => void }
|
||||
}
|
||||
|
||||
it('registers a modelReady listener separate from modelLoadingEnd', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const events = vi
|
||||
.mocked(mockLoad3d.addEventListener!)
|
||||
.mock.calls.map(([event]) => event)
|
||||
expect(events).toContain('modelReady')
|
||||
expect(events).toContain('modelLoadingEnd')
|
||||
expect(composable).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not call captureThumbnail when asset preview is unsupported', async () => {
|
||||
const { isAssetPreviewSupported } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(false)
|
||||
|
||||
const { handler } = await getModelReadyHandler()
|
||||
handler()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockLoad3d.captureThumbnail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures thumbnail and persists it when asset preview is supported and a model_file widget has a value', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'',
|
||||
'cube.glb'
|
||||
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
|
||||
|
||||
const modelWidget = {
|
||||
name: 'model_file',
|
||||
value: 'cube.glb [output]'
|
||||
} as unknown as IWidget
|
||||
mockNode.widgets = [modelWidget]
|
||||
|
||||
const { handler } = await getModelReadyHandler()
|
||||
handler()
|
||||
// Two awaits: one for captureThumbnail, one for fetch().blob() chain.
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockLoad3d.captureThumbnail).toHaveBeenCalledWith(256, 256)
|
||||
expect(persistThumbnail).toHaveBeenCalledWith(
|
||||
'cube.glb',
|
||||
expect.any(Blob)
|
||||
)
|
||||
})
|
||||
|
||||
it('skips persistence when the model widget has no value', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
|
||||
mockNode.widgets = [
|
||||
{ name: 'model_file', value: '' } as unknown as IWidget
|
||||
]
|
||||
|
||||
const { handler } = await getModelReadyHandler()
|
||||
handler()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockLoad3d.captureThumbnail).not.toHaveBeenCalled()
|
||||
expect(persistThumbnail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows captureThumbnail rejections silently', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'',
|
||||
'broken.glb'
|
||||
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
|
||||
vi.mocked(mockLoad3d.captureThumbnail!).mockRejectedValue(
|
||||
new Error('webgl context lost')
|
||||
)
|
||||
mockNode.widgets = [
|
||||
{ name: 'model_file', value: 'broken.glb' } as unknown as IWidget
|
||||
]
|
||||
|
||||
const { handler } = await getModelReadyHandler()
|
||||
expect(() => handler()).not.toThrow()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(persistThumbnail).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -818,24 +818,24 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
applyGizmoConfigToLoad3d()
|
||||
isFirstModelLoad = false
|
||||
},
|
||||
modelReady: () => {
|
||||
if (!load3d || !isAssetPreviewSupported()) return
|
||||
|
||||
if (load3d && isAssetPreviewSupported()) {
|
||||
const node = nodeRef.value
|
||||
const node = nodeRef.value
|
||||
const modelWidget = node?.widgets?.find(
|
||||
(w) => w.name === 'model_file' || w.name === 'image'
|
||||
)
|
||||
const value = modelWidget?.value
|
||||
if (typeof value !== 'string' || !value) return
|
||||
|
||||
const modelWidget = node?.widgets?.find(
|
||||
(w) => w.name === 'model_file' || w.name === 'image'
|
||||
)
|
||||
const value = modelWidget?.value
|
||||
if (typeof value === 'string' && value) {
|
||||
const filename = value.trim().replace(/\s*\[output\]$/, '')
|
||||
const modelName = Load3dUtils.splitFilePath(filename)[1]
|
||||
load3d
|
||||
.captureThumbnail(256, 256)
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(modelName, blob))
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
const filename = value.trim().replace(/\s*\[output\]$/, '')
|
||||
const modelName = Load3dUtils.splitFilePath(filename)[1]
|
||||
load3d
|
||||
.captureThumbnail(256, 256)
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(modelName, blob))
|
||||
.catch(() => {})
|
||||
},
|
||||
skeletonVisibilityChange: (value: boolean) => {
|
||||
modelConfig.value.showSkeleton = value
|
||||
@@ -911,9 +911,13 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
|
||||
const handleFitToViewer = () => {
|
||||
if (load3d) {
|
||||
load3d.fitToViewer()
|
||||
}
|
||||
if (!load3d) return
|
||||
load3d.fitToViewer()
|
||||
|
||||
if (!modelConfig.value.gizmo) return
|
||||
const transform = load3d.getGizmoTransform()
|
||||
modelConfig.value.gizmo.position = transform.position
|
||||
modelConfig.value.gizmo.scale = transform.scale
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
|
||||
124
src/extensions/core/editAttention.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
registerExtension: vi.fn(),
|
||||
ui: { settings: { addSetting: vi.fn() } }
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
addWeightToParentheses,
|
||||
findNearestEnclosure,
|
||||
incrementWeight
|
||||
} from './editAttention'
|
||||
|
||||
describe('incrementWeight', () => {
|
||||
it('increments a weight by the given delta', () => {
|
||||
expect(incrementWeight('1.0', 0.05)).toBe('1.05')
|
||||
})
|
||||
|
||||
it('decrements a weight by the given delta', () => {
|
||||
expect(incrementWeight('1.05', -0.05)).toBe('1')
|
||||
})
|
||||
|
||||
it('returns the original string when weight is not a number', () => {
|
||||
expect(incrementWeight('abc', 0.05)).toBe('abc')
|
||||
})
|
||||
|
||||
it('rounds correctly and avoids floating point accumulation', () => {
|
||||
expect(incrementWeight('1.1', 0.1)).toBe('1.2')
|
||||
})
|
||||
|
||||
it('can produce a weight of zero', () => {
|
||||
expect(incrementWeight('0.05', -0.05)).toBe('0')
|
||||
})
|
||||
|
||||
it('produces negative weights', () => {
|
||||
expect(incrementWeight('0.0', -0.05)).toBe('-0.05')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findNearestEnclosure', () => {
|
||||
it.each([
|
||||
[
|
||||
'returns start and end of a simple parenthesized expression',
|
||||
'(cat)',
|
||||
2,
|
||||
{ start: 1, end: 4 }
|
||||
],
|
||||
[
|
||||
'finds enclosure when cursor is on opening paren',
|
||||
'(cat)',
|
||||
0,
|
||||
{ start: 1, end: 4 }
|
||||
],
|
||||
['returns null when there are no parentheses', 'cat dog', 3, null],
|
||||
['returns null when cursor is outside any enclosure', '(cat) dog', 7, null],
|
||||
[
|
||||
'finds the inner enclosure when cursor is on nested content',
|
||||
'(outer (inner) end)',
|
||||
9,
|
||||
{ start: 8, end: 13 }
|
||||
],
|
||||
[
|
||||
'finds the outer enclosure when cursor is on outer content',
|
||||
'(outer (inner) end)',
|
||||
2,
|
||||
{ start: 1, end: 18 }
|
||||
],
|
||||
['returns null for empty string', '', 0, null],
|
||||
[
|
||||
'returns null when opening paren has no matching closing paren',
|
||||
'(cat',
|
||||
2,
|
||||
null
|
||||
]
|
||||
])('%s', (_, text, cursor, expected) => {
|
||||
expect(findNearestEnclosure(text, cursor)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addWeightToParentheses', () => {
|
||||
it.each([
|
||||
['adds weight 1.0 to a bare parenthesized token', '(cat)', '(cat:1.0)'],
|
||||
[
|
||||
'leaves a token that already has a weight unchanged',
|
||||
'(cat:1.5)',
|
||||
'(cat:1.5)'
|
||||
],
|
||||
['leaves a token without parentheses unchanged', 'cat', 'cat'],
|
||||
[
|
||||
'leaves a token with scientific notation weight unchanged',
|
||||
'(cat:1e-3)',
|
||||
'(cat:1e-3)'
|
||||
],
|
||||
[
|
||||
'leaves a token with a negative weight unchanged',
|
||||
'(cat:-0.5)',
|
||||
'(cat:-0.5)'
|
||||
],
|
||||
[
|
||||
'adds weight to a multi-word parenthesized token',
|
||||
'(cat dog)',
|
||||
'(cat dog:1.0)'
|
||||
],
|
||||
[
|
||||
'adds weight when colon-number appears in content but no trailing weight exists',
|
||||
'(time 12:30)',
|
||||
'(time 12:30:1.0)'
|
||||
],
|
||||
[
|
||||
'preserves existing weight on a digit-ending token name',
|
||||
'(v2:1.5)',
|
||||
'(v2:1.5)'
|
||||
],
|
||||
[
|
||||
'preserves existing weight on a LoRA name ending in a digit',
|
||||
'(sdxl1:0.8)',
|
||||
'(sdxl1:0.8)'
|
||||
]
|
||||
])('%s', (_, input, expected) => {
|
||||
expect(addWeightToParentheses(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,62 @@
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys
|
||||
type Enclosure = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export function incrementWeight(weight: string, delta: number): string {
|
||||
const floatWeight = parseFloat(weight)
|
||||
if (isNaN(floatWeight)) return weight
|
||||
const newWeight = floatWeight + delta
|
||||
return String(Number(newWeight.toFixed(10)))
|
||||
}
|
||||
|
||||
export function findNearestEnclosure(
|
||||
text: string,
|
||||
cursorPos: number
|
||||
): Enclosure | null {
|
||||
let start = cursorPos
|
||||
let end = cursorPos
|
||||
let openCount = 0
|
||||
let closeCount = 0
|
||||
|
||||
if (text[cursorPos] === '(') {
|
||||
end = cursorPos + 1
|
||||
} else {
|
||||
while (start >= 0) {
|
||||
start--
|
||||
if (text[start] === '(' && openCount === closeCount) break
|
||||
if (text[start] === '(') openCount++
|
||||
if (text[start] === ')') closeCount++
|
||||
}
|
||||
if (start < 0) return null
|
||||
openCount = 0
|
||||
closeCount = 0
|
||||
}
|
||||
|
||||
while (end < text.length) {
|
||||
if (text[end] === ')' && openCount === closeCount) break
|
||||
if (text[end] === '(') openCount++
|
||||
if (text[end] === ')') closeCount++
|
||||
end++
|
||||
}
|
||||
if (end === text.length) return null
|
||||
|
||||
return { start: start + 1, end: end }
|
||||
}
|
||||
|
||||
export function addWeightToParentheses(text: string): string {
|
||||
const parenMatch = text.match(/^\((.*)\)$/)
|
||||
if (!parenMatch) return text
|
||||
const innerText = parenMatch[1]
|
||||
// A time-like pattern (e.g. "12:30") is preceded by whitespace or string-start;
|
||||
// everything else ending in ":number" is a weight, including digit-ending names like "v2:1.5".
|
||||
const looksLikeTime = /(?:^|\s)\d{1,2}:\d{2}$/.test(innerText)
|
||||
const hasTrailingWeight =
|
||||
!looksLikeTime && /:[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?$/.test(innerText)
|
||||
return hasTrailingWeight ? text : `(${innerText}:1.0)`
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.EditAttention',
|
||||
@@ -18,65 +74,6 @@ app.registerExtension({
|
||||
defaultValue: 0.05
|
||||
})
|
||||
|
||||
function incrementWeight(weight: string, delta: number): string {
|
||||
const floatWeight = parseFloat(weight)
|
||||
if (isNaN(floatWeight)) return weight
|
||||
const newWeight = floatWeight + delta
|
||||
return String(Number(newWeight.toFixed(10)))
|
||||
}
|
||||
|
||||
type Enclosure = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
function findNearestEnclosure(
|
||||
text: string,
|
||||
cursorPos: number
|
||||
): Enclosure | null {
|
||||
let start = cursorPos,
|
||||
end = cursorPos
|
||||
let openCount = 0,
|
||||
closeCount = 0
|
||||
|
||||
// Find opening parenthesis before cursor
|
||||
while (start >= 0) {
|
||||
start--
|
||||
if (text[start] === '(' && openCount === closeCount) break
|
||||
if (text[start] === '(') openCount++
|
||||
if (text[start] === ')') closeCount++
|
||||
}
|
||||
if (start < 0) return null
|
||||
|
||||
openCount = 0
|
||||
closeCount = 0
|
||||
|
||||
// Find closing parenthesis after cursor
|
||||
while (end < text.length) {
|
||||
if (text[end] === ')' && openCount === closeCount) break
|
||||
if (text[end] === '(') openCount++
|
||||
if (text[end] === ')') closeCount++
|
||||
end++
|
||||
}
|
||||
if (end === text.length) return null
|
||||
|
||||
return { start: start + 1, end: end }
|
||||
}
|
||||
|
||||
function addWeightToParentheses(text: string): string {
|
||||
const parenRegex = /^\((.*)\)$/
|
||||
const parenMatch = text.match(parenRegex)
|
||||
|
||||
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/
|
||||
const floatMatch = text.match(floatRegex)
|
||||
|
||||
if (parenMatch && !floatMatch) {
|
||||
return `(${parenMatch[1]}:1.0)`
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function editAttention(event: KeyboardEvent) {
|
||||
// @ts-expect-error Runtime narrowing not impl.
|
||||
const inputField: HTMLTextAreaElement = event.composedPath()[0]
|
||||
@@ -92,7 +89,6 @@ app.registerExtension({
|
||||
let end = inputField.selectionEnd
|
||||
let selectedText = inputField.value.substring(start, end)
|
||||
|
||||
// If there is no selection, attempt to find the nearest enclosure, or select the current word
|
||||
if (!selectedText) {
|
||||
const nearestEnclosure = findNearestEnclosure(inputField.value, start)
|
||||
if (nearestEnclosure) {
|
||||
@@ -100,7 +96,6 @@ app.registerExtension({
|
||||
end = nearestEnclosure.end
|
||||
selectedText = inputField.value.substring(start, end)
|
||||
} else {
|
||||
// Select the current word, find the start and end of the word
|
||||
const delimiters = ' .,\\/!?%^*;:{}=-_`~()\r\n\t'
|
||||
|
||||
while (
|
||||
@@ -122,37 +117,30 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
// If the selection ends with a space, remove it
|
||||
if (selectedText[selectedText.length - 1] === ' ') {
|
||||
const selectionEndsWithSpace =
|
||||
selectedText[selectedText.length - 1] === ' '
|
||||
if (selectionEndsWithSpace) {
|
||||
selectedText = selectedText.substring(0, selectedText.length - 1)
|
||||
end -= 1
|
||||
}
|
||||
|
||||
// If there are parentheses left and right of the selection, select them
|
||||
if (
|
||||
inputField.value[start - 1] === '(' &&
|
||||
inputField.value[end] === ')'
|
||||
) {
|
||||
const selectionIsSurroundedByParens =
|
||||
inputField.value[start - 1] === '(' && inputField.value[end] === ')'
|
||||
if (selectionIsSurroundedByParens) {
|
||||
start -= 1
|
||||
end += 1
|
||||
selectedText = inputField.value.substring(start, end)
|
||||
}
|
||||
|
||||
// If the selection is not enclosed in parentheses, add them
|
||||
if (
|
||||
selectedText[0] !== '(' ||
|
||||
selectedText[selectedText.length - 1] !== ')'
|
||||
) {
|
||||
selectedText = `(${selectedText})`
|
||||
}
|
||||
const selectionIsNotEnclosedInParens =
|
||||
selectedText[0] !== '(' || selectedText[selectedText.length - 1] !== ')'
|
||||
if (selectionIsNotEnclosedInParens) selectedText = `(${selectedText})`
|
||||
|
||||
// If the selection does not have a weight, add a weight of 1.0
|
||||
selectedText = addWeightToParentheses(selectedText)
|
||||
|
||||
// Increment the weight
|
||||
const weightDelta = event.key === 'ArrowUp' ? delta : -delta
|
||||
const updatedText = selectedText.replace(
|
||||
/\((.*):([+-]?\d+(?:\.\d+)?)\)/,
|
||||
/\((.*):([+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?)\)/,
|
||||
(_, text, weight) => {
|
||||
weight = incrementWeight(weight, weightDelta)
|
||||
if (weight == 1) {
|
||||
|
||||
@@ -187,7 +187,8 @@ describe('Load3DConfiguration.silentOnNotFound propagation', () => {
|
||||
setLightIntensity: vi.fn(),
|
||||
setHDRIIntensity: vi.fn(),
|
||||
setHDRIAsBackground: vi.fn(),
|
||||
setHDRIEnabled: vi.fn()
|
||||
setHDRIEnabled: vi.fn(),
|
||||
emitModelReady: vi.fn()
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
@@ -250,6 +251,52 @@ describe('Load3DConfiguration.silentOnNotFound propagation', () => {
|
||||
silentOnNotFound: false
|
||||
})
|
||||
})
|
||||
|
||||
it('emits modelReady AFTER setCameraState so thumbnail capture sees the restored view', async () => {
|
||||
const load3d = makeLoad3dMock()
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
const cameraState = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 1,
|
||||
cameraType: 'perspective' as const
|
||||
}
|
||||
config.configure({
|
||||
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
|
||||
loadFolder: 'output',
|
||||
cameraState: cameraState as unknown as Parameters<
|
||||
Load3DConfiguration['configure']
|
||||
>[0]['cameraState']
|
||||
})
|
||||
await flush()
|
||||
|
||||
const setCameraStateMock = vi.mocked(load3d.setCameraState)
|
||||
const emitModelReadyMock = vi.mocked(load3d.emitModelReady)
|
||||
expect(setCameraStateMock).toHaveBeenCalledWith(cameraState)
|
||||
expect(emitModelReadyMock).toHaveBeenCalledTimes(1)
|
||||
expect(setCameraStateMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
emitModelReadyMock.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('emits modelReady even when no saved cameraState is provided', async () => {
|
||||
const load3d = makeLoad3dMock()
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
config.configure({
|
||||
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
|
||||
loadFolder: 'output'
|
||||
})
|
||||
await flush()
|
||||
expect(vi.mocked(load3d.emitModelReady)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('configureForSaveMesh also emits modelReady once the load resolves', async () => {
|
||||
const load3d = makeLoad3dMock()
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
config.configureForSaveMesh('output', 'model.glb')
|
||||
await flush()
|
||||
expect(vi.mocked(load3d.emitModelReady)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnnotatedFilename', () => {
|
||||
|
||||
@@ -94,7 +94,7 @@ class Load3DConfiguration {
|
||||
)
|
||||
|
||||
if (filePath) {
|
||||
onModelWidgetUpdate(filePath)
|
||||
void onModelWidgetUpdate(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ class Load3DConfiguration {
|
||||
silentOnNotFound
|
||||
)
|
||||
if (modelWidget.value) {
|
||||
onModelWidgetUpdate(modelWidget.value)
|
||||
void onModelWidgetUpdate(modelWidget.value)
|
||||
}
|
||||
|
||||
const originalCallback = modelWidget.callback
|
||||
@@ -131,7 +131,7 @@ class Load3DConfiguration {
|
||||
})
|
||||
|
||||
modelWidget.callback = (value: string | number | boolean | object) => {
|
||||
onModelWidgetUpdate(value)
|
||||
void onModelWidgetUpdate(value)
|
||||
|
||||
if (originalCallback) {
|
||||
originalCallback(value)
|
||||
@@ -309,6 +309,8 @@ class Load3DConfiguration {
|
||||
}
|
||||
isFirstLoad = false
|
||||
}
|
||||
|
||||
this.load3d.emitModelReady()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ function makeInstance() {
|
||||
// and ViewHelper, none of which are available in happy-dom. Skip it and
|
||||
// inject stubs directly onto the prototype instance so delegation methods
|
||||
// can be exercised in isolation.
|
||||
const eventManager = { emitEvent: vi.fn() }
|
||||
const load3d = Object.create(Load3d.prototype) as Load3d
|
||||
Object.assign(load3d, {
|
||||
gizmoManager: gizmo,
|
||||
@@ -92,6 +93,7 @@ function makeInstance() {
|
||||
controlsManager,
|
||||
viewHelperManager,
|
||||
animationManager,
|
||||
eventManager,
|
||||
adapterRef: { current: null },
|
||||
forceRender: vi.fn(),
|
||||
handleResize: vi.fn()
|
||||
@@ -106,6 +108,7 @@ function makeInstance() {
|
||||
controlsManager,
|
||||
viewHelperManager,
|
||||
animationManager,
|
||||
eventManager,
|
||||
forceRender: load3d.forceRender as ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
@@ -764,4 +767,86 @@ describe('Load3d', () => {
|
||||
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('emitModelReady', () => {
|
||||
it('emits a modelReady event on the eventManager', () => {
|
||||
ctx.load3d.emitModelReady()
|
||||
expect(ctx.eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'modelReady',
|
||||
null
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureThumbnail', () => {
|
||||
function setupForCapture() {
|
||||
const cameraStub = {
|
||||
toggleCamera: vi.fn(),
|
||||
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
|
||||
getCameraState: vi.fn().mockReturnValue({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 1,
|
||||
cameraType: 'perspective'
|
||||
}),
|
||||
setCameraState: vi.fn(),
|
||||
perspectiveCamera: new THREE.PerspectiveCamera()
|
||||
}
|
||||
const controlsStub = {
|
||||
controls: { target: { copy: vi.fn() }, update: vi.fn() }
|
||||
}
|
||||
const sceneCaptureMock = vi.fn().mockResolvedValue({
|
||||
scene: 'data:image/png;base64,scene',
|
||||
mask: 'm',
|
||||
normal: 'n'
|
||||
})
|
||||
const modelGroup = new THREE.Group()
|
||||
modelGroup.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)))
|
||||
Object.assign(ctx.load3d, {
|
||||
cameraManager: cameraStub,
|
||||
controlsManager: controlsStub,
|
||||
sceneManager: {
|
||||
...ctx.sceneManager,
|
||||
gridHelper: { visible: true },
|
||||
captureScene: sceneCaptureMock
|
||||
},
|
||||
modelManager: {
|
||||
...ctx.modelManager,
|
||||
currentModel: modelGroup
|
||||
}
|
||||
})
|
||||
return { cameraStub, sceneCaptureMock }
|
||||
}
|
||||
|
||||
it('throws when no model is loaded', async () => {
|
||||
Object.assign(ctx.load3d, {
|
||||
modelManager: { ...ctx.modelManager, currentModel: null }
|
||||
})
|
||||
|
||||
await expect(ctx.load3d.captureThumbnail()).rejects.toThrow(
|
||||
'No model loaded for thumbnail capture'
|
||||
)
|
||||
})
|
||||
|
||||
it('forces a render after restoring camera state so the visible canvas reflects the live scene, not the offscreen capture', async () => {
|
||||
const { cameraStub } = setupForCapture()
|
||||
|
||||
const result = await ctx.load3d.captureThumbnail(64, 64)
|
||||
|
||||
expect(result).toBe('data:image/png;base64,scene')
|
||||
expect(cameraStub.setCameraState).toHaveBeenCalled()
|
||||
// forceRender must be called AFTER the live state has been restored.
|
||||
const setCameraOrder = cameraStub.setCameraState.mock.invocationCallOrder
|
||||
const forceRenderOrder = ctx.forceRender.mock.invocationCallOrder
|
||||
expect(forceRenderOrder.at(-1)).toBeGreaterThan(setCameraOrder.at(-1)!)
|
||||
})
|
||||
|
||||
it('still forces a render in finally when captureScene rejects', async () => {
|
||||
const { sceneCaptureMock } = setupForCapture()
|
||||
sceneCaptureMock.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
await expect(ctx.load3d.captureThumbnail(64, 64)).rejects.toThrow('boom')
|
||||
expect(ctx.forceRender).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -632,6 +632,10 @@ class Load3d {
|
||||
this.eventManager.removeEventListener(event, callback)
|
||||
}
|
||||
|
||||
emitModelReady(): void {
|
||||
this.eventManager.emitEvent('modelReady', null)
|
||||
}
|
||||
|
||||
refreshViewport(): void {
|
||||
this.handleResize()
|
||||
}
|
||||
@@ -812,6 +816,8 @@ class Load3d {
|
||||
}
|
||||
this.cameraManager.setCameraState(savedState)
|
||||
this.controlsManager.controls?.update()
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
114
src/extensions/core/previewAny.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const capturedExtensions: ComfyExtension[] = []
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension: (ext: ComfyExtension) => {
|
||||
capturedExtensions.push(ext)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
element: { readOnly: boolean }
|
||||
callback?: (value: unknown) => void
|
||||
value: unknown
|
||||
hidden: boolean
|
||||
label: string
|
||||
serialize?: boolean
|
||||
}
|
||||
|
||||
const createdWidgets: MockWidget[] = []
|
||||
|
||||
vi.mock('@/scripts/widgets', () => {
|
||||
const create =
|
||||
(kind: string) =>
|
||||
(
|
||||
node: { widgets?: MockWidget[] },
|
||||
name: string,
|
||||
_info: unknown,
|
||||
_app: unknown
|
||||
) => {
|
||||
const widget: MockWidget = {
|
||||
name,
|
||||
options: {},
|
||||
element: { readOnly: false },
|
||||
value: kind === 'BOOLEAN' ? false : '',
|
||||
hidden: false,
|
||||
label: ''
|
||||
}
|
||||
node.widgets = node.widgets ?? []
|
||||
node.widgets.push(widget)
|
||||
createdWidgets.push(widget)
|
||||
return { widget }
|
||||
}
|
||||
return {
|
||||
ComfyWidgets: {
|
||||
MARKDOWN: create('MARKDOWN'),
|
||||
STRING: create('STRING'),
|
||||
BOOLEAN: create('BOOLEAN')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('PreviewAny extension', () => {
|
||||
beforeEach(async () => {
|
||||
capturedExtensions.length = 0
|
||||
createdWidgets.length = 0
|
||||
vi.resetModules()
|
||||
await import('./previewAny')
|
||||
})
|
||||
|
||||
async function setupNode() {
|
||||
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
|
||||
expect(ext).toBeDefined()
|
||||
|
||||
const nodeType = { prototype: {} } as unknown as Parameters<
|
||||
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
|
||||
>[0]
|
||||
const nodeData = { name: 'PreviewAny' } as Parameters<
|
||||
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
|
||||
>[1]
|
||||
|
||||
await ext!.beforeRegisterNodeDef!(
|
||||
nodeType,
|
||||
nodeData,
|
||||
{} as Parameters<NonNullable<ComfyExtension['beforeRegisterNodeDef']>>[2]
|
||||
)
|
||||
|
||||
const node: { widgets?: MockWidget[] } = {}
|
||||
const proto = nodeType.prototype as { onNodeCreated?: () => void }
|
||||
proto.onNodeCreated!.call(node)
|
||||
return node
|
||||
}
|
||||
|
||||
it('excludes preview widgets from the API prompt to prevent re-execution', async () => {
|
||||
await setupNode()
|
||||
|
||||
const previewMarkdown = createdWidgets.find(
|
||||
(w) => w.name === 'preview_markdown'
|
||||
)
|
||||
const previewText = createdWidgets.find((w) => w.name === 'preview_text')
|
||||
const previewMode = createdWidgets.find((w) => w.name === 'previewMode')
|
||||
|
||||
expect(previewMarkdown).toBeDefined()
|
||||
expect(previewText).toBeDefined()
|
||||
expect(previewMode).toBeDefined()
|
||||
|
||||
// widget.options.serialize === false is what executionUtil.graphToPrompt
|
||||
// checks to exclude a widget from the API prompt sent to the backend.
|
||||
// Without this, post-execution widget value updates (the rendered preview
|
||||
// text) get serialized as inputs, change the cache signature, and cause
|
||||
// the node to re-execute on the next prompt.
|
||||
expect(previewMarkdown!.options.serialize).toBe(false)
|
||||
expect(previewText!.options.serialize).toBe(false)
|
||||
expect(previewMode!.options.serialize).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -57,6 +57,7 @@ useExtensionService().registerExtension({
|
||||
showValueWidget.hidden = true
|
||||
showValueWidget.options.hidden = true
|
||||
showValueWidget.options.read_only = true
|
||||
showValueWidget.options.serialize = false
|
||||
showValueWidget.element.readOnly = true
|
||||
showValueWidget.serialize = false
|
||||
|
||||
@@ -64,8 +65,14 @@ useExtensionService().registerExtension({
|
||||
showValueWidgetPlain.hidden = false
|
||||
showValueWidgetPlain.options.hidden = false
|
||||
showValueWidgetPlain.options.read_only = true
|
||||
showValueWidgetPlain.options.serialize = false
|
||||
showValueWidgetPlain.element.readOnly = true
|
||||
showValueWidgetPlain.serialize = false
|
||||
|
||||
// The previewMode toggle is a frontend-only display preference and
|
||||
// is not declared in the backend INPUT_TYPES, so it must not be
|
||||
// serialized into the API prompt (would alter the cache signature).
|
||||
showAsPlaintextWidget.widget.options.serialize = false
|
||||
}
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted
|
||||
|
||||
@@ -450,6 +450,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
|
||||
const sourceNode = this.subgraph.getNodeById(entry.sourceNodeId)
|
||||
if (!sourceNode) return linkedWidgetNames.has(entry.sourceWidgetName)
|
||||
if (sourceNode.type === 'PrimitiveNode') return true
|
||||
|
||||
const hasSourceWidget =
|
||||
sourceNode.widgets?.some(
|
||||
|
||||