Compare commits
56 Commits
v1.44.17
...
codex/back
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe0538c43 | ||
|
|
08dcc96aa3 | ||
|
|
0a75aca0f3 | ||
|
|
47bbb659e6 | ||
|
|
b059c22def | ||
|
|
2806fab735 | ||
|
|
1ef579abf4 | ||
|
|
9530605c3b | ||
|
|
e6ead5631a | ||
|
|
a9b9de2b10 | ||
|
|
9be62a1845 | ||
|
|
d4d2089663 | ||
|
|
7aaade0f68 | ||
|
|
e45e249ed9 | ||
|
|
5878840f26 | ||
|
|
128ca823fd | ||
|
|
97a80cad22 | ||
|
|
1ab9752af8 | ||
|
|
e469611f6d | ||
|
|
ad6cbf7cbe | ||
|
|
5ebf5e03ae | ||
|
|
d3ab2be695 | ||
|
|
37f0fbcbef | ||
|
|
6ef051f200 | ||
|
|
0788e71394 | ||
|
|
d3f802de10 | ||
|
|
d78c630d36 | ||
|
|
aa4343a98b | ||
|
|
270c7e34f4 | ||
|
|
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 |
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/**'
|
||||
|
||||
@@ -9,6 +9,7 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
|
||||
|
||||
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
|
||||
import { getDefaultLocale } from '@frontend-locales/localeConfig'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
function buildLocale<
|
||||
@@ -167,7 +168,7 @@ const messages: Record<string, LocaleMessages> = {
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
locale: navigator.language.split('-')[0] || 'en',
|
||||
locale: getDefaultLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
// Ignore warnings for locale options as each option is in its own language.
|
||||
|
||||
@@ -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: 92 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 100 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"
|
||||
>
|
||||
|
||||
@@ -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.
|
||||
@@ -200,13 +217,20 @@ export class VueNodeHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for the Enter Subgraph footer button.
|
||||
*/
|
||||
getSubgraphEnterButton(nodeId?: string): Locator {
|
||||
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
const editButton = this.getSubgraphEnterButton(nodeId)
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
|
||||
@@ -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) ?? [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,10 +39,32 @@ class ComfyQueueButton {
|
||||
await this.dropdownButton.click()
|
||||
return new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
}
|
||||
|
||||
public async openOptions() {
|
||||
const options = new ComfyQueueButtonOptions(this.actionbar.page)
|
||||
if (!(await options.menu.isVisible())) {
|
||||
await this.dropdownButton.click()
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
class ComfyQueueButtonOptions {
|
||||
constructor(public readonly page: Page) {}
|
||||
public readonly menu: Locator
|
||||
public readonly modeItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menu = page.getByRole('menu')
|
||||
this.modeItems = this.menu.getByRole('menuitem')
|
||||
}
|
||||
|
||||
public modeItem(name: string) {
|
||||
return this.menu.getByRole('menuitem', { name, exact: true })
|
||||
}
|
||||
|
||||
public async selectMode(name: string) {
|
||||
await this.modeItem(name).click()
|
||||
}
|
||||
|
||||
public async setMode(mode: AutoQueueMode) {
|
||||
await this.page.evaluate((mode) => {
|
||||
|
||||
@@ -95,6 +95,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
public readonly nodePreview: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
@@ -103,6 +104,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -215,11 +215,12 @@ export class AssetHelper {
|
||||
return this.store.size
|
||||
}
|
||||
private handleListAssets(route: Route, url: URL) {
|
||||
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
|
||||
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
|
||||
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
|
||||
|
||||
let filtered = this.getFilteredAssets(includeTags)
|
||||
let filtered = this.getFilteredAssets(includeTags, excludeTags)
|
||||
if (limit > 0) {
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
@@ -296,15 +297,29 @@ export class AssetHelper {
|
||||
this.paginationOptions = null
|
||||
this.uploadResponse = null
|
||||
}
|
||||
private getFilteredAssets(tags: string[]): Asset[] {
|
||||
private getFilteredAssets(
|
||||
includeTags: string[],
|
||||
excludeTags: string[]
|
||||
): Asset[] {
|
||||
const assets = [...this.store.values()]
|
||||
if (tags.length === 0) return assets
|
||||
|
||||
return assets.filter((asset) =>
|
||||
tags.every((tag) => (asset.tags ?? []).includes(tag))
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
|
||||
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssetTagParam(value: string | null): string[] {
|
||||
return (
|
||||
value
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export function createAssetHelper(
|
||||
page: Page,
|
||||
...operators: AssetOperator[]
|
||||
|
||||
@@ -6,6 +6,71 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
function readFilePayload(filePath: string) {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
return { bufferArray, fileName, fileType }
|
||||
}
|
||||
|
||||
async function dispatchFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const target = document.activeElement ?? document
|
||||
target.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
async function interceptNextFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
document.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
type PasteFileOptions = {
|
||||
mode?: 'keyboard' | 'direct'
|
||||
}
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
private readonly keyboard: KeyboardHelper,
|
||||
@@ -20,43 +85,20 @@ export class ClipboardHelper {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
async pasteFile(
|
||||
filePath: string,
|
||||
{ mode = 'keyboard' }: PasteFileOptions = {}
|
||||
): Promise<void> {
|
||||
const payload = readFilePayload(filePath)
|
||||
|
||||
// Register a one-time capturing-phase listener that intercepts the next
|
||||
// paste event and injects file data onto clipboardData.
|
||||
await this.page.evaluate(
|
||||
({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
if (mode === 'keyboard') {
|
||||
await interceptNextFilePaste(this.page, payload)
|
||||
await this.paste()
|
||||
return
|
||||
}
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const syntheticEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(syntheticEvent)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
},
|
||||
{ bufferArray, fileName, fileType }
|
||||
)
|
||||
|
||||
// Trigger a real Ctrl+V keystroke — the capturing listener above will
|
||||
// intercept it and re-dispatch with file data attached.
|
||||
await this.paste()
|
||||
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
|
||||
// Dispatch the app-level paste event with file clipboardData directly.
|
||||
await dispatchFilePaste(this.page, payload)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -8,6 +8,7 @@ export const TestIds = {
|
||||
toolbar: 'side-toolbar',
|
||||
nodeLibrary: 'node-library-tree',
|
||||
nodeLibrarySearch: 'node-library-search',
|
||||
nodePreviewCard: 'node-preview-card',
|
||||
workflows: 'workflows-sidebar',
|
||||
modeToggle: 'mode-toggle'
|
||||
},
|
||||
@@ -75,7 +76,15 @@ export const TestIds = {
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
updatePassword: 'update-password-dialog',
|
||||
cloudNotification: 'cloud-notification-dialog'
|
||||
cloudNotification: 'cloud-notification-dialog',
|
||||
openSharedWorkflow: 'open-shared-workflow-dialog',
|
||||
openSharedWorkflowTitle: 'open-shared-workflow-title',
|
||||
openSharedWorkflowClose: 'open-shared-workflow-close',
|
||||
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
|
||||
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
|
||||
openSharedWorkflowOpenWithoutImporting:
|
||||
'open-shared-workflow-open-without-importing',
|
||||
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
@@ -91,6 +100,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 +125,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 +223,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'
|
||||
|
||||
250
browser_tests/fixtures/sharedWorkflowImportFixture.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type {
|
||||
Asset,
|
||||
ImportPublishedAssetsRequest,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
|
||||
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
|
||||
|
||||
export const sharedWorkflowImportScenario = {
|
||||
shareId: 'shared-missing-media-e2e',
|
||||
workflowId: 'shared-missing-media-workflow',
|
||||
publishedAssetId: 'published-input-asset-1',
|
||||
inputFileName: 'shared_imported_image.png'
|
||||
} as const
|
||||
|
||||
export type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
export interface SharedWorkflowImportMocks {
|
||||
resetAndStartRecording: () => void
|
||||
getImportBody: () => ImportPublishedAssetsRequest | undefined
|
||||
getRequestEvents: () => SharedWorkflowRequestEvent[]
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
|
||||
}
|
||||
|
||||
const defaultInputFileName = '00000000000000000000000Aexample.png'
|
||||
|
||||
const sharedWorkflowAsset: AssetInfo = {
|
||||
id: sharedWorkflowImportScenario.publishedAssetId,
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const sharedWorkflowResponse: SharedWorkflowResponse = {
|
||||
share_id: sharedWorkflowImportScenario.shareId,
|
||||
workflow_id: sharedWorkflowImportScenario.workflowId,
|
||||
name: 'Shared Missing Media Workflow',
|
||||
listed: true,
|
||||
publish_time: '2026-05-01T00:00:00Z',
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
last_node_id: 10,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'LoadImage',
|
||||
pos: [50, 200],
|
||||
size: [315, 314],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'IMAGE',
|
||||
type: 'IMAGE',
|
||||
links: null
|
||||
},
|
||||
{
|
||||
name: 'MASK',
|
||||
type: 'MASK',
|
||||
links: null
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'LoadImage'
|
||||
},
|
||||
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
assets: [sharedWorkflowAsset]
|
||||
}
|
||||
|
||||
export const sharedWorkflowImportFixture = base.extend<{
|
||||
sharedWorkflowImportMocks: SharedWorkflowImportMocks
|
||||
}>({
|
||||
sharedWorkflowImportMocks: async ({ page }, use) => {
|
||||
const mocks = await mockSharedWorkflowImportFlow(page)
|
||||
await use(mocks)
|
||||
}
|
||||
})
|
||||
|
||||
async function mockSharedWorkflowImportFlow(
|
||||
page: Page
|
||||
): Promise<SharedWorkflowImportMocks> {
|
||||
let isRecording = false
|
||||
let importEndpointCalled = false
|
||||
let importBody: ImportPublishedAssetsRequest | undefined
|
||||
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
|
||||
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
const requestEvents: SharedWorkflowRequestEvent[] = []
|
||||
|
||||
function resetPublicInclusiveInputAssetResponseWaiter() {
|
||||
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
|
||||
if (isRecording) requestEvents.push(event)
|
||||
}
|
||||
|
||||
await page.route(
|
||||
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(sharedWorkflowResponse)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await page.route('**/api/assets/import', async (route) => {
|
||||
recordRequestEvent('import')
|
||||
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
|
||||
importEndpointCalled = true
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
|
||||
// Excludes `/api/assets/import` so the specific route above
|
||||
// remains isolated from the general asset listing mock.
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const includeTags = getTagParam(url, 'include_tags')
|
||||
const isInputAssetRequest = includeTags.includes('input')
|
||||
const includesPublicAssets =
|
||||
url.searchParams.get('include_public') === 'true'
|
||||
const isPublicInclusiveInputAssetRequest =
|
||||
isInputAssetRequest && includesPublicAssets
|
||||
const isAfterImportPublicInclusiveInputAssetRequest =
|
||||
isPublicInclusiveInputAssetRequest && importEndpointCalled
|
||||
|
||||
if (isPublicInclusiveInputAssetRequest) {
|
||||
recordRequestEvent(
|
||||
importEndpointCalled
|
||||
? 'input-assets-including-public-after-import'
|
||||
: 'input-assets-including-public-before-import'
|
||||
)
|
||||
}
|
||||
|
||||
const allAssets = [
|
||||
defaultInputAsset,
|
||||
...(importEndpointCalled ? [importedInputAsset] : [])
|
||||
]
|
||||
const assets = includeTags.length
|
||||
? allAssets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: allAssets
|
||||
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
|
||||
if (isAfterImportPublicInclusiveInputAssetRequest) {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
resetAndStartRecording: () => {
|
||||
isRecording = true
|
||||
importEndpointCalled = false
|
||||
importBody = undefined
|
||||
requestEvents.length = 0
|
||||
resetPublicInclusiveInputAssetResponseWaiter()
|
||||
},
|
||||
getImportBody: () => importBody,
|
||||
getRequestEvents: () => [...requestEvents],
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
|
||||
publicInclusiveInputAssetResponseAfterImport
|
||||
}
|
||||
}
|
||||
|
||||
function getTagParam(url: URL, key: string): string[] {
|
||||
return (
|
||||
url.searchParams
|
||||
.get(key)
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
28
browser_tests/fixtures/utils/selectionToolboxMoreOptions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export async function openMoreOptionsMenu(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
) {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
if (nodes.length === 0) {
|
||||
throw new Error(`No "${nodeTitle}" nodes found`)
|
||||
}
|
||||
|
||||
await nodes[0].centerOnNode()
|
||||
await nodes[0].click('title')
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
return menu
|
||||
}
|
||||
@@ -133,6 +133,29 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
})
|
||||
|
||||
test('GET /assets filters by exclude_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_INPUT_IMAGE),
|
||||
withAsset({
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'missing-input',
|
||||
tags: ['input', 'missing']
|
||||
})
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
|
||||
)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets.map((asset) => asset.id)).toEqual([
|
||||
STABLE_INPUT_IMAGE.id
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
47
browser_tests/tests/i18nLocaleFallback.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
|
||||
//
|
||||
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
|
||||
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
|
||||
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
|
||||
// must render translated strings, not literal i18n keys like
|
||||
// 'sideToolbar.labels.assets'.
|
||||
test.describe('i18n locale fallback', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: 'de-DE',
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: ['de-DE', 'de'],
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
// Default sidebar size on small viewports hides labels; force normal so
|
||||
// .side-bar-button-label is rendered for the assertion.
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.waitForAppReady()
|
||||
})
|
||||
|
||||
test('sidebar labels render translated strings, not raw i18n keys', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
|
||||
const labelTexts = await page
|
||||
.getByTestId('side-toolbar')
|
||||
.locator('.side-bar-button-label')
|
||||
.allTextContents()
|
||||
|
||||
expect(labelTexts.length).toBeGreaterThan(0)
|
||||
for (const text of labelTexts) {
|
||||
expect(text).not.toContain('sideToolbar.labels')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
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 |
65
browser_tests/tests/nodeContextMenuShapeSubmenu.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
|
||||
|
||||
test.describe(
|
||||
'Node context menu shape submenu (FE-570)',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
})
|
||||
|
||||
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
|
||||
const popover = comfyPage.page
|
||||
.locator('.p-popover')
|
||||
.filter({ hasText: 'Default' })
|
||||
await expect(popover).toBeVisible()
|
||||
await expect(popover).toContainText('Box')
|
||||
await expect(popover).toContainText('Card')
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(popoverBox!.width).toBeGreaterThan(0)
|
||||
expect(popoverBox!.height).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test('Shape popover opens when the menu fits in the viewport', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
|
||||
.toBe('visible')
|
||||
|
||||
await menu.getByRole('menuitem', { name: 'Shape' }).click()
|
||||
await expectShapePopoverVisible(comfyPage)
|
||||
})
|
||||
|
||||
test('Shape popover opens even when the menu must scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
|
||||
await shapeItem.scrollIntoViewIfNeeded()
|
||||
await shapeItem.click()
|
||||
await expectShapePopoverVisible(comfyPage)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
63
browser_tests/tests/queueButtonModes.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
|
||||
const runOnChangeLabel = queueModeLabels[1]
|
||||
|
||||
test.describe('Queue button modes', { tag: '@ui' }, () => {
|
||||
test('Run button is visible in topbar', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
|
||||
const options = await comfyPage.actionbar.queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
await expect(options.modeItems).toHaveText(queueModeLabels)
|
||||
})
|
||||
|
||||
test('Selecting a non-default mode updates the Run button label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const queueButton = comfyPage.actionbar.queueButton
|
||||
const options = await queueButton.openOptions()
|
||||
|
||||
await expect(options.menu).toBeVisible()
|
||||
await options.selectMode(runOnChangeLabel)
|
||||
|
||||
await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
|
||||
})
|
||||
|
||||
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route('**/api/prompt', async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.actionbar.queueButton.primaryButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -54,14 +54,44 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
.toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('info button opens properties panel', async ({ comfyPage }) => {
|
||||
test('info button opens the right-side info tab in new menu mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(panel).toContainText('KSampler')
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('info button is hidden when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button visible with multi-select', async ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -18,70 +19,19 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
const openMoreOptions = (comfyPage: ComfyPage) =>
|
||||
openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
|
||||
// Drag the KSampler to the center of the screen
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()
|
||||
if (!viewportSize) {
|
||||
throw new Error(
|
||||
'Viewport size is null - page may not be properly initialized'
|
||||
)
|
||||
}
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height / 2
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
test('hides Node Info from More Options menu when the new menu is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
|
||||
await moreOptionsBtn.click()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisible = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisibleAfterClick) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Could not open More Options menu - popover not showing')
|
||||
}
|
||||
|
||||
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
|
||||
exact: true
|
||||
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
|
||||
name: 'Node Info'
|
||||
})
|
||||
await expect(nodeInfoButton).toBeVisible()
|
||||
await nodeInfoButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(nodeInfoButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
|
||||
@@ -90,11 +40,14 @@ test.describe(
|
||||
)[0]
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).hover()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Box', { exact: true })
|
||||
).toBeVisible()
|
||||
await comfyPage.page.getByText('Box', { exact: true }).click()
|
||||
// Shape now opens via body-appended popover (FE-570); a hover no
|
||||
// longer reveals the submenu — match the Color flow and click.
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).click()
|
||||
const shapePopover = comfyPage.page
|
||||
.locator('.p-popover')
|
||||
.filter({ hasText: 'Default' })
|
||||
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
|
||||
await shapePopover.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)
|
||||
|
||||
148
browser_tests/tests/sharedWorkflowMissingMedia.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
sharedWorkflowImportFixture,
|
||||
sharedWorkflowImportScenario
|
||||
} from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
const IMPORT_ORDER_TIMEOUT_MS = 5_000
|
||||
|
||||
async function expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await expect(async () => {
|
||||
const events = mocks.getRequestEvents()
|
||||
const importIndex = events.indexOf('import')
|
||||
const afterImportIndex = events.indexOf(
|
||||
'input-assets-including-public-after-import'
|
||||
)
|
||||
|
||||
expect(
|
||||
events,
|
||||
'public-inclusive input assets must not be scanned before import'
|
||||
).not.toContain('input-assets-including-public-before-import')
|
||||
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
|
||||
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
|
||||
importIndex
|
||||
)
|
||||
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage: ComfyPage,
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
|
||||
.toEqual([])
|
||||
}
|
||||
|
||||
async function openPanelAndExpectNoMissingMedia(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
const page = comfyPage.page
|
||||
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const panel = new PropertiesPanelHelper(page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
|
||||
|
||||
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
|
||||
// Missing media only surfaces the overlay when the Errors tab is enabled
|
||||
// (src/stores/executionErrorStore.ts).
|
||||
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
sharedWorkflowImportMocks.resetAndStartRecording()
|
||||
await comfyPage.page.goto(
|
||||
new URL(
|
||||
`/?share=${sharedWorkflowImportScenario.shareId}`,
|
||||
comfyPage.url
|
||||
).toString()
|
||||
)
|
||||
await comfyPage.waitForAppReady()
|
||||
})
|
||||
|
||||
test('imports shared media before loading workflow so missing media is not surfaced', async ({
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((node) => ({
|
||||
type: node.type,
|
||||
value: node.widgets?.[0]?.value
|
||||
}))
|
||||
)
|
||||
)
|
||||
.toEqual([
|
||||
{
|
||||
type: 'LoadImage',
|
||||
value: sharedWorkflowImportScenario.inputFileName
|
||||
}
|
||||
])
|
||||
await expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
|
||||
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
|
||||
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
|
||||
share_id: sharedWorkflowImportScenario.shareId
|
||||
})
|
||||
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
|
||||
await openPanelAndExpectNoMissingMedia(comfyPage)
|
||||
})
|
||||
})
|
||||
@@ -120,4 +120,13 @@ test.describe('Node library sidebar V2', () => {
|
||||
await expect(options.first()).toBeVisible()
|
||||
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('Blueprint previews include description', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.blueprintsTab.click()
|
||||
|
||||
await tab.getNode('test blueprint').hover()
|
||||
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
|
||||
await expect(tab.nodePreview).toContainText('Inverts the image')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -106,6 +106,54 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.route(
|
||||
'**/workflows/published/test-share-id',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
share_id: 'test-share-id',
|
||||
workflow_id: 'wf-1',
|
||||
name: 'Shared Workflow',
|
||||
listed: true,
|
||||
publish_time: new Date().toISOString(),
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {}
|
||||
},
|
||||
assets: []
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
||||
await comfyPage.page.goto(`${comfyPage.url}/api/users`)
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
}, comfyPage.id)
|
||||
await comfyPage.page.goto(
|
||||
new URL('/?share=test-share-id', comfyPage.url).toString()
|
||||
)
|
||||
await comfyPage.waitForAppReady()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
})
|
||||
|
||||
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
|
||||
|
||||
@@ -131,48 +179,51 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
test('Falls back to English templates when locale file not found', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Set locale to a language that doesn't have a template file
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
|
||||
// Pick a shipped LTR locale and simulate its template index returning 404.
|
||||
// (Previously this test used 'de', but unsupported locales are now
|
||||
// clamped to 'en' at boot so they never hit the template fallback path.
|
||||
// 'fa' would also work but flips document.dir to rtl, which can leak
|
||||
// into adjacent specs in the same worker.)
|
||||
const locale = 'tr'
|
||||
|
||||
// Wait for the German request (expected to 404)
|
||||
const germanRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.de.json'
|
||||
await comfyPage.page.route(
|
||||
`**/templates/index.${locale}.json`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Wait for the fallback English request
|
||||
const englishRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.json'
|
||||
)
|
||||
|
||||
// Intercept the German file to simulate a 404
|
||||
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
})
|
||||
|
||||
// Allow the English index to load normally
|
||||
await comfyPage.page.route('**/templates/index.json', (route) =>
|
||||
route.continue()
|
||||
)
|
||||
|
||||
// Load the templates dialog
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', locale)
|
||||
|
||||
const localeRequestPromise = comfyPage.page.waitForRequest(
|
||||
`**/templates/index.${locale}.json`
|
||||
)
|
||||
const englishRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.json'
|
||||
)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Verify German was requested first, then English as fallback
|
||||
const germanRequest = await germanRequestPromise
|
||||
const localeRequest = await localeRequestPromise
|
||||
const englishRequest = await englishRequestPromise
|
||||
|
||||
expect(germanRequest.url()).toContain('templates/index.de.json')
|
||||
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
|
||||
expect(englishRequest.url()).toContain('templates/index.json')
|
||||
|
||||
// Verify English titles are shown as fallback
|
||||
await expect(
|
||||
comfyPage.page.getByRole('main').getByText('All Templates')
|
||||
).toBeVisible()
|
||||
// Assert on rendered content, not just the container — the container
|
||||
// testid is present even when the dialog body is empty, which would let
|
||||
// a regression where the fallback fetch succeeds but no cards render
|
||||
// pass silently.
|
||||
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('template cards are dynamically sized and responsive', async ({
|
||||
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
@@ -75,6 +75,24 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await expect(renamedNode).toBeVisible()
|
||||
})
|
||||
|
||||
test('should open node info in the right side panel via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Node Info')
|
||||
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('should copy and paste node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -39,6 +41,19 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
|
||||
}
|
||||
|
||||
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
|
||||
const box = await button.boundingBox()
|
||||
if (!box) throw new Error('Tab button has no bounding box')
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height * 0.75
|
||||
}
|
||||
await comfyPage.canvasOps.dragAndDrop(start, {
|
||||
x: start.x + 120,
|
||||
y: start.y + 80
|
||||
})
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -90,6 +105,63 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
await comfyPage.nodeOps.addNode(
|
||||
'ModelSamplingFlux',
|
||||
{},
|
||||
{
|
||||
x: 500,
|
||||
y: 200
|
||||
}
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = node.getByText('Show advanced inputs')
|
||||
const widgets = node.locator('.lg-node-widget')
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const beforePos = await node.boundingBox()
|
||||
if (!beforePos) throw new Error('Node has no bounding box')
|
||||
|
||||
await dragFromTabButton(comfyPage, showButton)
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const afterPos = await node.boundingBox()
|
||||
if (!afterPos) throw new Error('Node missing after drag')
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const beforePos = await subgraphNode.getPosition()
|
||||
|
||||
await dragFromTabButton(
|
||||
comfyPage,
|
||||
comfyPage.vueNodes.getSubgraphEnterButton('2')
|
||||
)
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
const afterPos = await subgraphNode.getPosition()
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should move all selected nodes together when dragging one with Meta held', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
@@ -13,6 +14,9 @@ import {
|
||||
ExecutionHelper,
|
||||
buildKSamplerError
|
||||
} from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
@@ -20,6 +24,62 @@ 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'
|
||||
const LOAD_IMAGE_INPUT_NAME = 'image'
|
||||
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
|
||||
|
||||
function buildLoadImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'LoadImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
|
||||
details: '',
|
||||
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function surfaceLoadImageMissingInputError(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[loadImageId]: buildLoadImageRequiredInputError()
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
}
|
||||
|
||||
async function selectLoadImageNodeForPaste(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(nodeId))
|
||||
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
|
||||
window.app!.canvas.selectNode(node)
|
||||
window.app!.canvas.current_node = node
|
||||
}, loadImageId)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const loadImageId = String(loadImageNode.id)
|
||||
|
||||
return {
|
||||
loadImageId,
|
||||
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
|
||||
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
test('should display error state when node is missing (node from workflow is not installed)', async ({
|
||||
@@ -71,6 +131,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
|
||||
}) => {
|
||||
@@ -136,6 +249,74 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user drops an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('drop an image onto the Load Image node', async () => {
|
||||
const dropPosition =
|
||||
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
|
||||
if (!dropPosition) {
|
||||
throw new Error('Load Image node center must be available for drop')
|
||||
}
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
|
||||
dropPosition,
|
||||
waitForUpload: true
|
||||
})
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('clears error ring when user pastes an image file onto Load Image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { loadImageId, innerWrapper, imageWidget } =
|
||||
await setupLoadImageErrorScenario(comfyPage)
|
||||
|
||||
await test.step('queue with missing image input to surface the error', async () => {
|
||||
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('paste an image while Load Image is selected', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
|
||||
)
|
||||
.toBe('LoadImage')
|
||||
|
||||
const uploadResponse = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
// File clipboard contents cannot be seeded reliably in Playwright;
|
||||
// use the direct document paste mode to exercise usePaste.
|
||||
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
|
||||
mode: 'direct'
|
||||
})
|
||||
await uploadResponse
|
||||
await expect
|
||||
.poll(() => imageWidget.getValue())
|
||||
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
||||
})
|
||||
|
||||
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
|
||||
|
||||
|
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'
|
||||
],
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
/* Disable trackpad two-finger horizontal swipe back/forward navigation
|
||||
and other overscroll gestures. ComfyUI is a full-screen editor; the
|
||||
browser's overscroll behaviors only ever leave or break the workflow. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.17",
|
||||
"version": "1.44.19",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
13
packages/ingest-types/src/types.gen.ts
generated
@@ -524,9 +524,18 @@ export type ImportPublishedAssetsRequest = {
|
||||
*/
|
||||
published_asset_ids: Array<string>
|
||||
/**
|
||||
* The share ID of the published workflow these assets belong to. Required for authorization.
|
||||
* Optional. Share ID of the published workflow these assets belong to.
|
||||
* When provided (non-null, non-empty): all published_asset_ids must
|
||||
* belong to this share's workflow version; returns
|
||||
* 400/CodeInvalidAssets if the share is not found or any asset does
|
||||
* not belong to it.
|
||||
* When omitted, null, or empty string: no share-scoped validation is
|
||||
* performed and the assets are validated only against global rules
|
||||
* (legacy behaviour, preserved for clients that have not yet adopted
|
||||
* share_id).
|
||||
*
|
||||
*/
|
||||
share_id: string
|
||||
share_id?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
packages/ingest-types/src/zod.gen.ts
generated
@@ -310,8 +310,8 @@ export const zImportPublishedAssetsResponse = z.object({
|
||||
* Request body for importing assets from a published workflow.
|
||||
*/
|
||||
export const zImportPublishedAssetsRequest = z.object({
|
||||
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
|
||||
share_id: z.string().min(1).max(64)
|
||||
published_asset_ids: z.array(z.string()),
|
||||
share_id: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
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;
|
||||
|
||||
@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
getFilePathSeparatorVariants,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -299,6 +301,42 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinFilePath', () => {
|
||||
it('joins subfolder and filename with normalized slash separators', () => {
|
||||
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
|
||||
'nested/folder/child/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('trims boundary separators without changing the filename body', () => {
|
||||
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
|
||||
'nested/folder/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the normalized filename when no subfolder is provided', () => {
|
||||
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
|
||||
})
|
||||
|
||||
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
|
||||
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
|
||||
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilePathSeparatorVariants', () => {
|
||||
it('returns slash and backslash variants for nested paths', () => {
|
||||
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
|
||||
'nested/folder/file.png',
|
||||
'nested\\folder\\file.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single value when no separator is present', () => {
|
||||
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendWorkflowJsonExt', () => {
|
||||
it('appends .app.json when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
|
||||
|
||||
@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function joinFilePath(
|
||||
subfolder: string | null | undefined,
|
||||
filename: string | null | undefined
|
||||
): string {
|
||||
const normalizedSubfolder = normalizeFilePathSeparators(
|
||||
subfolder ?? ''
|
||||
).replace(/^\/+|\/+$/g, '')
|
||||
const normalizedFilename = normalizeFilePathSeparators(
|
||||
filename ?? ''
|
||||
).replace(/^\/+/g, '')
|
||||
if (!normalizedSubfolder) return normalizedFilename
|
||||
if (!normalizedFilename) return normalizedSubfolder
|
||||
return `${normalizedSubfolder}/${normalizedFilename}`
|
||||
}
|
||||
|
||||
export function getFilePathSeparatorVariants(filepath: string): string[] {
|
||||
const slashPath = normalizeFilePathSeparators(filepath)
|
||||
const backslashPath = slashPath.replace(/\//g, '\\')
|
||||
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
|
||||
}
|
||||
|
||||
function normalizeFilePathSeparators(filepath: string): string {
|
||||
return filepath.replace(/[\\/]+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filepath into its filename and subfolder components.
|
||||
*
|
||||
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
|
||||
} {
|
||||
if (!filepath?.trim()) return { filename: '', subfolder: '' }
|
||||
|
||||
const normalizedPath = filepath
|
||||
.replace(/[\\/]+/g, '/') // Normalize path separators
|
||||
const normalizedPath = normalizeFilePathSeparators(filepath)
|
||||
.replace(/^\//, '') // Remove leading slash
|
||||
.replace(/\/$/, '') // Remove trailing slash
|
||||
|
||||
|
||||
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
|
||||
|
||||
20
src/base/wheelGestures.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Wheel events whose browser default would break the editing experience.
|
||||
* On macOS trackpads:
|
||||
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
|
||||
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
|
||||
* recovery short of a page reload.
|
||||
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
|
||||
* back/forward navigation, which leaves the workflow.
|
||||
*
|
||||
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
|
||||
* trackpad samples) intentionally falls on the false branch so native
|
||||
* vertical scroll wins on a tie.
|
||||
*
|
||||
* Components that intercept wheel events should suppress the default for
|
||||
* these gestures even when they otherwise let the browser scroll natively.
|
||||
*/
|
||||
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
@@ -40,7 +40,10 @@
|
||||
|
||||
<template #contentFilter>
|
||||
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<!-- Model Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedModelObjects"
|
||||
@@ -48,6 +51,7 @@
|
||||
class="w-[250px]"
|
||||
:label="modelFilterLabel"
|
||||
:options="modelOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -62,6 +66,7 @@
|
||||
v-model="selectedUseCaseObjects"
|
||||
:label="useCaseFilterLabel"
|
||||
:options="useCaseOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -76,6 +81,7 @@
|
||||
v-model="selectedRunsOnObjects"
|
||||
:label="runsOnFilterLabel"
|
||||
:options="runsOnOptions"
|
||||
:content-style="selectContentStyle"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
@@ -92,6 +98,7 @@
|
||||
v-model="sortBy"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
:content-style="selectContentStyle"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -416,6 +423,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -632,6 +640,8 @@ const selectedRunsOnObjects = computed({
|
||||
const loadingTemplate = ref<string | null>(null)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
const cardRefs = ref<HTMLElement[]>([])
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const selectContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
// Force re-render key for templates when sorting changes
|
||||
const templateListKey = ref(0)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="keybinding-panel flex flex-col gap-2"
|
||||
>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
v-model="filters['global'].value"
|
||||
@@ -15,10 +18,12 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<KeybindingPresetToolbar
|
||||
:preset-names="presetNames"
|
||||
:content-style="keybindingOverlayContentStyle"
|
||||
@presets-changed="refreshPresetList"
|
||||
/>
|
||||
<DropdownMenu
|
||||
:entries="menuEntries"
|
||||
:style="keybindingOverlayContentStyle"
|
||||
icon="icon-[lucide--ellipsis]"
|
||||
item-class="text-sm gap-2"
|
||||
button-size="unset"
|
||||
@@ -193,11 +198,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">
|
||||
@@ -237,6 +243,7 @@
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
:style="keybindingOverlayContentStyle"
|
||||
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
<ContextMenuItem
|
||||
@@ -313,6 +320,7 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
@@ -336,6 +344,8 @@ const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
const presetNames = ref<string[]>([])
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
{{ displayLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
|
||||
<SelectContent
|
||||
:style="contentStyle"
|
||||
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
|
||||
>
|
||||
<div class="max-w-60">
|
||||
<SelectItem
|
||||
value="default"
|
||||
@@ -46,6 +49,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -57,8 +61,9 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
|
||||
const { presetNames } = defineProps<{
|
||||
const { presetNames, contentStyle } = defineProps<{
|
||||
presetNames: string[]
|
||||
contentStyle?: StyleValue
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<a
|
||||
v-bind="props.action"
|
||||
class="flex items-center gap-2 px-3 py-1.5"
|
||||
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
|
||||
@click="onItemClick($event, item)"
|
||||
>
|
||||
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
@@ -21,20 +21,27 @@
|
||||
{{ item.shortcut }}
|
||||
</span>
|
||||
<i
|
||||
v-if="hasSubmenu || item.isColorSubmenu"
|
||||
v-if="hasSubmenu || item.isColorSubmenu || item.isShapeSubmenu"
|
||||
class="icon-[lucide--chevron-right] size-4 opacity-60"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
|
||||
<!-- Color picker menu (custom with color circles) -->
|
||||
<ColorPickerMenu
|
||||
<SubmenuPopover
|
||||
v-if="colorOption"
|
||||
ref="colorPickerMenu"
|
||||
key="color-picker-menu"
|
||||
ref="colorSubmenu"
|
||||
key="color-submenu"
|
||||
:option="colorOption"
|
||||
@submenu-click="handleColorSelect"
|
||||
@submenu-click="handleSubmenuSelect"
|
||||
/>
|
||||
|
||||
<SubmenuPopover
|
||||
v-if="shapeOption"
|
||||
ref="shapeSubmenu"
|
||||
key="shape-submenu"
|
||||
:option="shapeOption"
|
||||
@submenu-click="handleSubmenuSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -54,16 +61,18 @@ import type {
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
|
||||
import SubmenuPopover from './selectionToolbox/SubmenuPopover.vue'
|
||||
|
||||
interface ExtendedMenuItem extends MenuItem {
|
||||
isColorSubmenu?: boolean
|
||||
isShapeSubmenu?: boolean
|
||||
shortcut?: string
|
||||
originalOption?: MenuOption
|
||||
}
|
||||
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
|
||||
const colorSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
|
||||
const shapeSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const { menuOptions, bump } = useMoreOptionsMenu()
|
||||
@@ -150,21 +159,20 @@ useEventListener(
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
// Find color picker option
|
||||
const colorOption = computed(() =>
|
||||
menuOptions.value.find((opt) => opt.isColorPicker)
|
||||
)
|
||||
|
||||
// Check if option is the color picker
|
||||
function isColorOption(option: MenuOption): boolean {
|
||||
return Boolean(option.isColorPicker)
|
||||
}
|
||||
const shapeOption = computed(() =>
|
||||
menuOptions.value.find((opt) => opt.isShapePicker)
|
||||
)
|
||||
|
||||
// Convert MenuOption to PrimeVue MenuItem
|
||||
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
if (option.type === 'divider') return { separator: true }
|
||||
|
||||
const isColor = isColorOption(option)
|
||||
const isColor = Boolean(option.isColorPicker)
|
||||
const isShape = Boolean(option.isShapePicker)
|
||||
const usesPopover = isColor || isShape
|
||||
|
||||
const item: ExtendedMenuItem = {
|
||||
label: option.label,
|
||||
@@ -172,11 +180,14 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
disabled: option.disabled,
|
||||
shortcut: option.shortcut,
|
||||
isColorSubmenu: isColor,
|
||||
isShapeSubmenu: isShape,
|
||||
originalOption: option
|
||||
}
|
||||
|
||||
// Native submenus for non-color options
|
||||
if (option.hasSubmenu && option.submenu && !isColor) {
|
||||
// Submenus opened via popover (color, shape) deliberately omit `items` so
|
||||
// PrimeVue does not render a nested <ul> inside the scrollable root list,
|
||||
// which would be clipped when the menu overflows the viewport (FE-570).
|
||||
if (option.hasSubmenu && option.submenu && !usesPopover) {
|
||||
item.items = option.submenu.map((sub) => ({
|
||||
label: sub.label,
|
||||
icon: sub.icon,
|
||||
@@ -188,7 +199,6 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
|
||||
}))
|
||||
}
|
||||
|
||||
// Regular action items
|
||||
if (!option.hasSubmenu && option.action) {
|
||||
item.command = () => {
|
||||
option.action?.()
|
||||
@@ -245,17 +255,30 @@ function toggle(event: Event) {
|
||||
|
||||
defineExpose({ toggle, hide, isOpen, show })
|
||||
|
||||
function showColorPopover(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
const target = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
(el) => el.classList.contains('icon-[lucide--chevron-right]')
|
||||
) as HTMLElement
|
||||
colorPickerMenu.value?.toggle(event, target)
|
||||
function onItemClick(event: MouseEvent, item: ExtendedMenuItem) {
|
||||
if (item.isColorSubmenu) {
|
||||
openSubmenuPopover(event, colorSubmenu.value, shapeSubmenu.value)
|
||||
} else if (item.isShapeSubmenu) {
|
||||
openSubmenuPopover(event, shapeSubmenu.value, colorSubmenu.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle color selection
|
||||
function handleColorSelect(subOption: SubMenuOption) {
|
||||
function openSubmenuPopover(
|
||||
event: MouseEvent,
|
||||
target: InstanceType<typeof SubmenuPopover> | undefined,
|
||||
other: InstanceType<typeof SubmenuPopover> | undefined
|
||||
) {
|
||||
if (!target) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
other?.hide()
|
||||
const anchor = Array.from((event.currentTarget as HTMLElement).children).find(
|
||||
(el) => el.classList.contains('icon-[lucide--chevron-right]')
|
||||
) as HTMLElement
|
||||
target.toggle(event, anchor)
|
||||
}
|
||||
|
||||
function handleSubmenuSelect(subOption: SubMenuOption) {
|
||||
subOption.action()
|
||||
hide()
|
||||
}
|
||||
@@ -270,11 +293,17 @@ function constrainMenuHeight() {
|
||||
if (!rootList) return
|
||||
|
||||
const rect = rootList.getBoundingClientRect()
|
||||
const maxHeight = window.innerHeight - rect.top - 8
|
||||
if (maxHeight > 0) {
|
||||
rootList.style.maxHeight = `${maxHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
}
|
||||
const availableHeight = window.innerHeight - rect.top - 8
|
||||
if (availableHeight <= 0) return
|
||||
|
||||
// Setting overflow-y to auto/scroll on the root <ul> coerces overflow-x
|
||||
// to a non-visible value too (CSS spec), which clips horizontally-opening
|
||||
// submenus like Shape. Only apply the constraint when content truly
|
||||
// overflows so the common case keeps overflow visible.
|
||||
if (rootList.scrollHeight <= availableHeight) return
|
||||
|
||||
rootList.style.maxHeight = `${availableHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
}
|
||||
|
||||
function onMenuShow() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fireEvent, render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -29,6 +30,26 @@ function createMockExtensionService(): ReturnType<typeof useExtensionService> {
|
||||
>
|
||||
}
|
||||
|
||||
const { settingGetMock } = vi.hoisted(() => ({
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
const defaultSettingValues: Record<string, unknown> = {
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': true,
|
||||
'Comfy.Load3D.3DViewerEnable': true
|
||||
}
|
||||
|
||||
function mockSettingValues(overrides: Record<string, unknown> = {}) {
|
||||
const settingValues = {
|
||||
...defaultSettingValues,
|
||||
...overrides
|
||||
}
|
||||
settingGetMock.mockImplementation(
|
||||
(key: string): unknown => settingValues[key] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
// Mock the composables and services
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: vi.fn(() => ({
|
||||
@@ -79,10 +100,7 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Load3D.3DViewerEnable') return true
|
||||
return null
|
||||
})
|
||||
get: settingGetMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -128,7 +146,7 @@ describe('SelectionToolbox', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
|
||||
canvasStore = useCanvasStore()
|
||||
nodeDefMock = {
|
||||
type: 'TestNode',
|
||||
@@ -139,6 +157,7 @@ describe('SelectionToolbox', () => {
|
||||
canvasStore.canvas = createMockCanvas()
|
||||
|
||||
vi.resetAllMocks()
|
||||
mockSettingValues()
|
||||
})
|
||||
|
||||
function renderComponent(props = {}): { container: Element } {
|
||||
@@ -231,6 +250,42 @@ describe('SelectionToolbox', () => {
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not show info button when legacy menu uses the new node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': true
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not show info button when legacy menu uses the legacy node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should show info button when new menu uses the legacy node library', () => {
|
||||
mockSettingValues({
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.NodeLibrary.NewDesign': false
|
||||
})
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
const { container } = renderComponent()
|
||||
|
||||
expect(container.querySelector('.info-button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should show color picker for all selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<DeleteButton v-if="showDelete" />
|
||||
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="showInfoButton" />
|
||||
<VerticalDivider v-if="canOpenNodeInfo && showAnyPrimaryActions" />
|
||||
<InfoButton v-if="canOpenNodeInfo" />
|
||||
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
@@ -105,9 +105,8 @@ const {
|
||||
isSingleImageNode,
|
||||
hasAny3DNodeSelected,
|
||||
hasOutputNodesSelected,
|
||||
nodeDef
|
||||
canOpenNodeInfo
|
||||
} = useSelectionState()
|
||||
const showInfoButton = computed(() => !!nodeDef.value)
|
||||
|
||||
const showColorPicker = computed(() => hasAnySelection.value)
|
||||
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -9,19 +8,20 @@ import { createI18n } from 'vue-i18n'
|
||||
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { openPanelMock } = vi.hoisted(() => ({
|
||||
openPanelMock: vi.fn()
|
||||
const { openNodeInfoMock, trackUiButtonClickedMock } = vi.hoisted(() => ({
|
||||
openNodeInfoMock: vi.fn(),
|
||||
trackUiButtonClickedMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
useRightSidePanelStore: () => ({
|
||||
openPanel: openPanelMock
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => ({
|
||||
openNodeInfo: openNodeInfoMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: vi.fn()
|
||||
trackUiButtonClicked: trackUiButtonClickedMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -39,8 +39,8 @@ describe('InfoButton', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
openNodeInfoMock.mockReturnValue(true)
|
||||
})
|
||||
|
||||
const renderComponent = () => {
|
||||
@@ -53,12 +53,29 @@ describe('InfoButton', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('should open the info panel on click', async () => {
|
||||
const clickNodeInfoButton = async () => {
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'Node Info' }))
|
||||
}
|
||||
|
||||
it('should open the node info panel on click', async () => {
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Node Info' }))
|
||||
await clickNodeInfoButton()
|
||||
|
||||
expect(openPanelMock).toHaveBeenCalledWith('info')
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not track the click when the node info panel is unavailable', async () => {
|
||||
openNodeInfoMock.mockReturnValue(false)
|
||||
renderComponent()
|
||||
|
||||
await clickNodeInfoButton()
|
||||
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,18 +15,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { openNodeInfo } = useSelectionState()
|
||||
|
||||
/**
|
||||
* Track node info button click and toggle node help.
|
||||
*/
|
||||
const onInfoClick = () => {
|
||||
if (!openNodeInfo()) return
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
rightSidePanelStore.openPanel('info')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-60'
|
||||
class: 'p-popover absolute z-60'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
@@ -90,8 +90,12 @@ const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||
const toggle = (event: Event, target?: HTMLElement) => {
|
||||
popoverRef.value?.toggle(event, target)
|
||||
}
|
||||
const hide = () => {
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
defineExpose({
|
||||
toggle
|
||||
toggle,
|
||||
hide
|
||||
})
|
||||
|
||||
const handleSubmenuClick = (subOption: SubMenuOption) => {
|
||||
@@ -21,20 +21,42 @@
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
v-model="selectedSpeed"
|
||||
:options="speedOptions"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
class="w-24"
|
||||
/>
|
||||
:model-value="selectedSpeed != null ? String(selectedSpeed) : undefined"
|
||||
@update:model-value="(val) => (selectedSpeed = Number(val))"
|
||||
>
|
||||
<SelectTrigger size="md" class="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in speedOptions"
|
||||
:key="opt.value"
|
||||
:value="String(opt.value)"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
v-model="selectedAnimation"
|
||||
:options="animations"
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
/>
|
||||
:model-value="
|
||||
selectedAnimation != null ? String(selectedAnimation) : undefined
|
||||
"
|
||||
@update:model-value="(val) => (selectedAnimation = Number(val))"
|
||||
>
|
||||
<SelectTrigger size="md" class="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="anim in animations"
|
||||
:key="anim.index"
|
||||
:value="String(anim.index)"
|
||||
>
|
||||
{{ anim.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full max-w-xs items-center gap-2 px-4">
|
||||
@@ -54,10 +76,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
@@ -5,20 +5,20 @@ import { ref } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="modelValue"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -15,21 +15,22 @@
|
||||
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
|
||||
>
|
||||
<Slider
|
||||
v-model="value"
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
const {
|
||||
icon = 'pi-expand',
|
||||
@@ -47,6 +48,12 @@ const {
|
||||
const value = defineModel<number>()
|
||||
const showSlider = ref(false)
|
||||
|
||||
const sliderValue = computed(() => [value.value ?? min])
|
||||
|
||||
function onSliderUpdate(val: number[] | undefined) {
|
||||
if (val?.length) value.value = val[0]
|
||||
}
|
||||
|
||||
const toggleSlider = () => {
|
||||
showSlider.value = !showSlider.value
|
||||
}
|
||||
|
||||
@@ -7,38 +7,81 @@ import { createI18n } from 'vue-i18n'
|
||||
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:aria-label="ariaLabel"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -2,34 +2,46 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.viewer.cameraType') }}</label>
|
||||
<Select
|
||||
v-model="cameraType"
|
||||
:options="cameras"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
>
|
||||
<Select v-model="cameraType">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="cam in cameras"
|
||||
:key="cam.value"
|
||||
:value="cam.value"
|
||||
>
|
||||
{{ cam.title }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="showFOVButton" class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.fov') }}</label>
|
||||
<Slider
|
||||
v-model="fov"
|
||||
:model-value="fovSliderValue"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
:aria-label="t('load3d.fov')"
|
||||
@update:model-value="onFovUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -41,4 +53,10 @@ const cameras = [
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const fovSliderValue = computed(() => [fov.value ?? 10])
|
||||
|
||||
function onFovUpdate(val: number[] | undefined) {
|
||||
if (val?.length) fov.value = val[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,22 +5,65 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
vi.mock('@/components/ui/select/Select.vue', async () => {
|
||||
const { provide } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: { modelValue: string },
|
||||
{ emit }: { emit: (event: string, value: string) => void }
|
||||
) {
|
||||
provide('selectModelValue', (): string => props.modelValue)
|
||||
provide('selectUpdate', (v: string): void =>
|
||||
emit('update:modelValue', v)
|
||||
)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
|
||||
const { inject, ref, onMounted } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'SelectContent',
|
||||
setup() {
|
||||
const selectModelValue = inject<() => string>('selectModelValue')
|
||||
const selectUpdate = inject<(v: string) => void>('selectUpdate')
|
||||
const el = ref<HTMLSelectElement | null>(null)
|
||||
onMounted(() => {
|
||||
if (el.value) el.value.value = selectModelValue?.() ?? ''
|
||||
})
|
||||
return {
|
||||
el,
|
||||
onChange: (e: Event) => {
|
||||
selectUpdate?.((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
}
|
||||
},
|
||||
template: '<select ref="el" @change="onChange"><slot /></select>'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
|
||||
default: {
|
||||
name: 'SelectItem',
|
||||
props: ['value'],
|
||||
template: '<option :value="value"><slot /></option>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
|
||||
default: { name: 'SelectTrigger', template: '<span />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
|
||||
default: { name: 'SelectValue', template: '<span />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<Select
|
||||
v-model="exportFormat"
|
||||
:options="exportFormats"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
>
|
||||
<Select v-model="exportFormat">
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="fmt in exportFormats"
|
||||
:key="fmt.value"
|
||||
:value="fmt.value"
|
||||
>
|
||||
{{ fmt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
@@ -19,10 +26,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
|
||||
@@ -17,19 +17,20 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -3,18 +3,20 @@
|
||||
<label>{{ $t('load3d.lightIntensity') }}</label>
|
||||
|
||||
<Slider
|
||||
v-model="lightIntensity"
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="lightIntensityMinimum"
|
||||
:max="lightIntensityMaximum"
|
||||
:step="lightAdjustmentIncrement"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
@@ -28,4 +30,12 @@ const lightIntensityMinimum = useSettingStore().get(
|
||||
const lightAdjustmentIncrement = useSettingStore().get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
const sliderValue = computed(() => [
|
||||
lightIntensity.value ?? lightIntensityMinimum
|
||||
])
|
||||
|
||||
function onSliderUpdate(val: number[] | undefined) {
|
||||
if (val?.length) lightIntensity.value = val[0]
|
||||
}
|
||||
</script>
|
||||
|
||||