mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-06 12:29:55 +00:00
Compare commits
34 Commits
refactor/d
...
feat/input
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c246e23a2f | ||
|
|
64b895028a | ||
|
|
6c1bf7a3cf | ||
|
|
b61e15293c | ||
|
|
899660b135 | ||
|
|
aeafff1ead | ||
|
|
f4fb7a458e | ||
|
|
71a3bd92b4 | ||
|
|
17d2870ef4 | ||
|
|
7a68943839 | ||
|
|
8912f4159a | ||
|
|
794b986954 | ||
|
|
a7b3515692 | ||
|
|
26f3f11a3e | ||
|
|
d9466947b2 | ||
|
|
bb96e3c95c | ||
|
|
df42b7a2a8 | ||
|
|
4f3a5ae184 | ||
|
|
c77c8a9476 | ||
|
|
380fae9a0d | ||
|
|
515f234143 | ||
|
|
61049425a3 | ||
|
|
661e3d7949 | ||
|
|
1624750a02 | ||
|
|
4cbf4994e9 | ||
|
|
86a3938d11 | ||
|
|
e11a1776ed | ||
|
|
161522b138 | ||
|
|
61144ea1d5 | ||
|
|
3ac08fd1da | ||
|
|
f1d5337181 | ||
|
|
c289640e99 | ||
|
|
dc7c97c5ac | ||
|
|
8340d7655f |
@@ -69,6 +69,9 @@
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
|
||||
# GLSL
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
|
||||
47
apps/website/src/components/AcademySection.vue
Normal file
47
apps/website/src/components/AcademySection.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
const features = [
|
||||
{ icon: '📚', label: 'Guided Tutorials' },
|
||||
{ icon: '🎥', label: 'Video Courses' },
|
||||
{ icon: '🛠️', label: 'Hands-on Projects' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-3xl px-6 text-center">
|
||||
<!-- Badge -->
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
>
|
||||
COMFY ACADEMY
|
||||
</span>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
|
||||
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Learn to build professional AI workflows with guided tutorials, video
|
||||
courses, and hands-on projects.
|
||||
</p>
|
||||
|
||||
<!-- Feature bullets -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.label"
|
||||
class="flex items-center gap-2 text-sm text-white"
|
||||
>
|
||||
<span aria-hidden="true">{{ feature.icon }}</span>
|
||||
<span>{{ feature.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/academy"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
EXPLORE ACADEMY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
66
apps/website/src/components/CTASection.vue
Normal file
66
apps/website/src/components/CTASection.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
const cards = [
|
||||
{
|
||||
icon: '🖥️',
|
||||
title: 'Comfy Desktop',
|
||||
description: 'Full power on your local machine. Free and open source.',
|
||||
cta: 'DOWNLOAD',
|
||||
href: '/download',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '☁️',
|
||||
title: 'Comfy Cloud',
|
||||
description: 'Run workflows in the cloud. No GPU required.',
|
||||
cta: 'TRY CLOUD',
|
||||
href: 'https://app.comfy.org',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Comfy API',
|
||||
description: 'Integrate AI generation into your applications.',
|
||||
cta: 'VIEW DOCS',
|
||||
href: 'https://docs.comfy.org',
|
||||
outlined: true
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
Choose Your Way to Comfy
|
||||
</h2>
|
||||
|
||||
<!-- CTA cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<a
|
||||
v-for="card in cards"
|
||||
:key="card.title"
|
||||
:href="card.href"
|
||||
class="flex flex-1 flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<span class="text-4xl" aria-hidden="true">{{ card.icon }}</span>
|
||||
<h3 class="mt-4 text-xl font-semibold text-white">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
<span
|
||||
class="mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
card.outlined
|
||||
? 'border border-brand-yellow text-brand-yellow'
|
||||
: 'bg-brand-yellow text-black'
|
||||
"
|
||||
>
|
||||
{{ card.cta }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
77
apps/website/src/components/CaseStudySpotlight.vue
Normal file
77
apps/website/src/components/CaseStudySpotlight.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<!-- TODO: Replace placeholder content with real quotes and case studies -->
|
||||
<script setup lang="ts">
|
||||
const studies = [
|
||||
{
|
||||
title: 'New Pipelines with Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'md:row-span-2'
|
||||
},
|
||||
{
|
||||
title: 'AI-Assisted Texture and Environment',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[300px] lg:col-span-2'
|
||||
},
|
||||
{
|
||||
title: 'Open-sourced the Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[200px]'
|
||||
},
|
||||
{
|
||||
title: 'Environment Generation',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: true,
|
||||
gridClass: 'min-h-[200px]'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-12">
|
||||
<h2 class="text-3xl font-bold text-white">Customer Stories</h2>
|
||||
<p class="mt-2 text-smoke-700">
|
||||
See how leading studios use Comfy in production
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Bento grid -->
|
||||
<div
|
||||
class="relative grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<article
|
||||
v-for="study in studies"
|
||||
:key="study.title"
|
||||
class="flex flex-col justify-end rounded-2xl border border-brand-yellow/30 p-6"
|
||||
:class="[
|
||||
study.gridClass,
|
||||
study.highlight ? 'bg-brand-yellow' : 'bg-charcoal-600'
|
||||
]"
|
||||
>
|
||||
<h3
|
||||
class="font-semibold"
|
||||
:class="study.highlight ? 'text-black' : 'text-white'"
|
||||
>
|
||||
{{ study.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="mt-2 text-sm"
|
||||
:class="study.highlight ? 'text-black/70' : 'text-smoke-700'"
|
||||
>
|
||||
{{ study.body }}
|
||||
</p>
|
||||
<a
|
||||
href="/case-studies"
|
||||
class="mt-4 text-sm underline"
|
||||
:class="study.highlight ? 'text-black' : 'text-brand-yellow'"
|
||||
>
|
||||
READ CASE STUDY
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
62
apps/website/src/components/GetStartedSection.vue
Normal file
62
apps/website/src/components/GetStartedSection.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Download & Sign Up',
|
||||
description: 'Get Comfy Desktop for free or create a Cloud account'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Load a Workflow',
|
||||
description:
|
||||
'Choose from thousands of community workflows or build your own'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Generate',
|
||||
description: 'Hit run and watch your AI workflow come to life'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-white/10 bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6 text-center">
|
||||
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
From download to your first AI-generated output in three simple steps
|
||||
</p>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div v-for="(step, index) in steps" :key="step.number" class="relative">
|
||||
<!-- Connecting line between steps (desktop only) -->
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<span class="text-6xl font-bold text-brand-yellow/20">
|
||||
{{ step.number }}
|
||||
</span>
|
||||
<h3 class="mt-2 text-xl font-semibold text-white">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/download"
|
||||
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
DOWNLOAD COMFY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
68
apps/website/src/components/HeroSection.vue
Normal file
68
apps/website/src/components/HeroSection.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
const ctaButtons = [
|
||||
{
|
||||
label: 'GET STARTED',
|
||||
href: 'https://app.comfy.org',
|
||||
variant: 'solid' as const
|
||||
},
|
||||
{
|
||||
label: 'LEARN MORE',
|
||||
href: '/about',
|
||||
variant: 'outline' as const
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex min-h-screen items-center overflow-hidden bg-black pt-16"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-7xl flex-col items-center gap-12 px-6 md:flex-row md:gap-0"
|
||||
>
|
||||
<!-- Left: C Monogram -->
|
||||
<div class="flex w-full items-center justify-center md:w-[55%]">
|
||||
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
|
||||
<div
|
||||
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
|
||||
>
|
||||
<!-- Gap on the right side to form "C" shape -->
|
||||
<div
|
||||
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Text content -->
|
||||
<div class="flex w-full flex-col items-start md:w-[45%]">
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
Professional Control of Visual AI
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-lg text-lg text-smoke-700">
|
||||
Comfy is the AI creation engine for visual professionals who demand
|
||||
control over every model, every parameter, and every output.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
<a
|
||||
v-for="btn in ctaButtons"
|
||||
:key="btn.label"
|
||||
:href="btn.href"
|
||||
class="rounded-full px-8 py-3 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
btn.variant === 'solid'
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-brand-yellow text-brand-yellow'
|
||||
"
|
||||
>
|
||||
{{ btn.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
26
apps/website/src/components/ManifestoSection.vue
Normal file
26
apps/website/src/components/ManifestoSection.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<!-- Decorative quote mark -->
|
||||
<span class="text-6xl text-brand-yellow opacity-30" aria-hidden="true">
|
||||
«
|
||||
</span>
|
||||
|
||||
<h2 class="text-4xl font-bold text-white md:text-5xl">
|
||||
Method, Not Magic
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
We believe in giving creators real control over AI. Not black boxes. Not
|
||||
magic buttons. But transparent, reproducible, node-by-node control over
|
||||
every step of the creative process.
|
||||
</p>
|
||||
|
||||
<!-- Separator line -->
|
||||
<div
|
||||
class="mx-auto mt-8 h-0.5 w-24 bg-brand-yellow opacity-30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
51
apps/website/src/components/ProductShowcase.vue
Normal file
51
apps/website/src/components/ProductShowcase.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<!-- TODO: Replace with actual workflow demo content -->
|
||||
<script setup lang="ts">
|
||||
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Section header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
|
||||
Watch how professionals build AI workflows with unprecedented control
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder video area -->
|
||||
<div
|
||||
class="mt-12 flex aspect-video items-center justify-center rounded-2xl border border-white/10 bg-charcoal-600"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Play button triangle -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature labels -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-6">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-brand-yellow"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm text-smoke-700">{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
58
apps/website/src/components/SocialProofBar.vue
Normal file
58
apps/website/src/components/SocialProofBar.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
const logos = [
|
||||
'Harman',
|
||||
'Tencent',
|
||||
'Nike',
|
||||
'HP',
|
||||
'Autodesk',
|
||||
'Apple',
|
||||
'Ubisoft',
|
||||
'Lucid',
|
||||
'Amazon',
|
||||
'Netflix',
|
||||
'Pixomondo',
|
||||
'EA'
|
||||
]
|
||||
|
||||
const metrics = [
|
||||
{ value: '60K+', label: 'Custom Nodes' },
|
||||
{ value: '106K+', label: 'GitHub Stars' },
|
||||
{ value: '500K+', label: 'Community Members' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-y border-white/10 bg-black py-16">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Heading -->
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
>
|
||||
Trusted by Industry Leaders
|
||||
</p>
|
||||
|
||||
<!-- Logo row -->
|
||||
<div
|
||||
class="mt-10 flex flex-wrap items-center justify-center gap-4 md:gap-6"
|
||||
>
|
||||
<span
|
||||
v-for="company in logos"
|
||||
:key="company"
|
||||
class="rounded-full border border-white/10 px-6 py-2 text-sm text-smoke-700"
|
||||
>
|
||||
{{ company }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metrics row -->
|
||||
<div
|
||||
class="mt-14 flex flex-col items-center justify-center gap-10 sm:flex-row sm:gap-12"
|
||||
>
|
||||
<div v-for="metric in metrics" :key="metric.label" class="text-center">
|
||||
<p class="text-3xl font-bold text-white">{{ metric.value }}</p>
|
||||
<p class="mt-1 text-sm text-smoke-700">{{ metric.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
92
apps/website/src/components/TestimonialsSection.vue
Normal file
92
apps/website/src/components/TestimonialsSection.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const activeFilter = ref('All')
|
||||
|
||||
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote:
|
||||
'Comfy has transformed our VFX pipeline. The node-based approach gives us unprecedented control over every step of the generation process.',
|
||||
name: 'Sarah Chen',
|
||||
title: 'Lead Technical Artist',
|
||||
company: 'Studio Alpha',
|
||||
industry: 'VFX'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'The level of control over AI generation is unmatched. We can iterate on game assets faster than ever before.',
|
||||
name: 'Marcus Rivera',
|
||||
title: 'Creative Director',
|
||||
company: 'PixelForge',
|
||||
industry: 'Gaming'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'We\u2019ve cut our iteration time by 70%. Comfy workflows let our team produce high-quality creative assets at scale.',
|
||||
name: 'Yuki Tanaka',
|
||||
title: 'Head of AI',
|
||||
company: 'CreativeX',
|
||||
industry: 'Advertising'
|
||||
}
|
||||
]
|
||||
|
||||
const filteredTestimonials = computed(() => {
|
||||
if (activeFilter.value === 'All') return testimonials
|
||||
return testimonials.filter((t) => t.industry === activeFilter.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
What Professionals Say
|
||||
</h2>
|
||||
|
||||
<!-- Industry filter pills -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-for="industry in industries"
|
||||
:key="industry"
|
||||
class="cursor-pointer rounded-full px-4 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
activeFilter === industry
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-white/10 text-smoke-700 hover:border-brand-yellow'
|
||||
"
|
||||
@click="activeFilter = industry"
|
||||
>
|
||||
{{ industry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Testimonial cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<article
|
||||
v-for="testimonial in filteredTestimonials"
|
||||
:key="testimonial.name"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
|
||||
>
|
||||
<blockquote class="text-base italic text-white">
|
||||
“{{ testimonial.quote }}”
|
||||
</blockquote>
|
||||
|
||||
<p class="mt-4 text-sm font-semibold text-white">
|
||||
{{ testimonial.name }}
|
||||
</p>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ testimonial.title }}, {{ testimonial.company }}
|
||||
</p>
|
||||
|
||||
<span
|
||||
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
|
||||
>
|
||||
{{ testimonial.industry }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
74
apps/website/src/components/UseCaseSection.vue
Normal file
74
apps/website/src/components/UseCaseSection.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<!-- TODO: Wire category content swap when final assets arrive -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const categories = [
|
||||
'VFX & Animation',
|
||||
'Creative Agencies',
|
||||
'Gaming',
|
||||
'eCommerce & Fashion',
|
||||
'Community & Hobbyists'
|
||||
]
|
||||
|
||||
const activeCategory = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="flex flex-col items-center gap-12 lg:flex-row lg:gap-8">
|
||||
<!-- Left placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
Built for Every Creative Industry
|
||||
</h2>
|
||||
|
||||
<nav
|
||||
class="mt-10 flex flex-col items-center gap-4"
|
||||
aria-label="Industry categories"
|
||||
>
|
||||
<button
|
||||
v-for="(category, index) in categories"
|
||||
:key="category"
|
||||
class="transition-colors"
|
||||
:class="
|
||||
index === activeCategory
|
||||
? 'text-2xl text-white'
|
||||
: 'text-xl text-ash-500 hover:text-white/70'
|
||||
"
|
||||
@click="activeCategory = index"
|
||||
>
|
||||
{{ category }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p class="mt-10 max-w-lg text-smoke-700">
|
||||
Powered by 60,000+ nodes, thousands of workflows, and a community
|
||||
that builds faster than any one company could.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/workflows"
|
||||
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
|
||||
>
|
||||
EXPLORE WORKFLOWS
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Right placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
67
apps/website/src/components/ValuePillars.vue
Normal file
67
apps/website/src/components/ValuePillars.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
const pillars = [
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Build',
|
||||
description:
|
||||
'Design complex AI workflows visually with our node-based editor'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Customize',
|
||||
description: 'Fine-tune every parameter across any model architecture'
|
||||
},
|
||||
{
|
||||
icon: '🔧',
|
||||
title: 'Refine',
|
||||
description:
|
||||
'Iterate on outputs with precision controls and real-time preview'
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
title: 'Automate',
|
||||
description:
|
||||
'Scale your workflows with batch processing and API integration'
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'Run',
|
||||
description: 'Deploy locally or in the cloud with identical results'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-16 text-center">
|
||||
<h2 class="text-3xl font-bold text-white md:text-4xl">
|
||||
The Building Blocks of AI Production
|
||||
</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Five powerful capabilities that give you complete control
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<article
|
||||
v-for="pillar in pillars"
|
||||
:key="pillar.title"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||
>
|
||||
{{ pillar.icon }}
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-white">
|
||||
{{ pillar.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ pillar.description }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
34
apps/website/src/pages/index.astro
Normal file
34
apps/website/src/pages/index.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../components/SiteNav.vue'
|
||||
import HeroSection from '../components/HeroSection.vue'
|
||||
import SocialProofBar from '../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../components/GetStartedSection.vue'
|
||||
import CTASection from '../components/CTASection.vue'
|
||||
import ManifestoSection from '../components/ManifestoSection.vue'
|
||||
import AcademySection from '../components/AcademySection.vue'
|
||||
import SiteFooter from '../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — Professional Control of Visual AI">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
34
apps/website/src/pages/zh-CN/index.astro
Normal file
34
apps/website/src/pages/zh-CN/index.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../../components/SiteNav.vue'
|
||||
import HeroSection from '../../components/HeroSection.vue'
|
||||
import SocialProofBar from '../../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../../components/GetStartedSection.vue'
|
||||
import CTASection from '../../components/CTASection.vue'
|
||||
import ManifestoSection from '../../components/ManifestoSection.vue'
|
||||
import AcademySection from '../../components/AcademySection.vue'
|
||||
import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
@@ -119,7 +119,7 @@ When writing new tests, follow these patterns:
|
||||
|
||||
```typescript
|
||||
// Import the test fixture
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
// Set up test environment if needed
|
||||
@@ -148,6 +148,12 @@ Always check for existing helpers and fixtures before implementing new ones:
|
||||
|
||||
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
|
||||
|
||||
### Import Conventions
|
||||
|
||||
- Prefer `@e2e/*` for imports within `browser_tests/`
|
||||
- Continue using `@/*` for imports from `src/`
|
||||
- Avoid introducing new deep relative imports within `browser_tests/` when the alias is available
|
||||
|
||||
### Key Testing Patterns
|
||||
|
||||
1. **Focus elements explicitly**:
|
||||
|
||||
68
browser_tests/assets/missing/missing_media_multiple.json
Normal file
68
browser_tests/assets/missing/missing_media_multiple.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"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": ["nonexistent_test_image_aaa.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "LoadImage",
|
||||
"pos": [450, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["nonexistent_test_image_bbb.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
42
browser_tests/assets/missing/missing_media_single.json
Normal file
42
browser_tests/assets/missing/missing_media_single.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"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": ["nonexistent_test_image_12345.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
BIN
browser_tests/assets/test_upload_image.png
Normal file
BIN
browser_tests/assets/test_upload_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
@@ -2,42 +2,44 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { sleep } from './utils/timing'
|
||||
import { comfyExpect } from './utils/customMatchers'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { ComfyActionbar } from '../helpers/actionbar'
|
||||
import { ComfyTemplates } from '../helpers/templates'
|
||||
import { ComfyMouse } from './ComfyMouse'
|
||||
import { VueNodeHelpers } from './VueNodeHelpers'
|
||||
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||
import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import { QueuePanel } from './components/QueuePanel'
|
||||
import { ComfyActionbar } from '@e2e/helpers/actionbar'
|
||||
import { ComfyTemplates } from '@e2e/helpers/templates'
|
||||
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { sleep } from '@e2e/fixtures/utils/timing'
|
||||
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
|
||||
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
|
||||
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
|
||||
import { ComfyNodeSearchBoxV2 } from '@e2e/fixtures/components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from '@e2e/fixtures/components/ContextMenu'
|
||||
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
|
||||
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
ModelLibrarySidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
NodeLibrarySidebarTabV2,
|
||||
WorkflowsSidebarTab
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { AssetsHelper } from './helpers/AssetsHelper'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { QueueHelper } from './helpers/QueueHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
import { SettingsHelper } from './helpers/SettingsHelper'
|
||||
import { AppModeHelper } from './helpers/AppModeHelper'
|
||||
import { SubgraphHelper } from './helpers/SubgraphHelper'
|
||||
import { ToastHelper } from './helpers/ToastHelper'
|
||||
import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import { assetPath } from './utils/paths'
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
|
||||
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { ModelLibraryHelper } from '@e2e/fixtures/helpers/ModelLibraryHelper'
|
||||
import { NodeOperationsHelper } from '@e2e/fixtures/helpers/NodeOperationsHelper'
|
||||
import { PerformanceHelper } from '@e2e/fixtures/helpers/PerformanceHelper'
|
||||
import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { ToastHelper } from '@e2e/fixtures/helpers/ToastHelper'
|
||||
import { WorkflowHelper } from '@e2e/fixtures/helpers/WorkflowHelper'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
@@ -56,7 +58,9 @@ class ComfyPropertiesPanel {
|
||||
|
||||
class ComfyMenu {
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTabV2: NodeLibrarySidebarTabV2 | null = null
|
||||
private _workflowsTab: WorkflowsSidebarTab | null = null
|
||||
private _topbar: Topbar | null = null
|
||||
|
||||
@@ -74,11 +78,21 @@ class ComfyMenu {
|
||||
return this.sideToolbar.locator('.side-bar-button')
|
||||
}
|
||||
|
||||
get modelLibraryTab() {
|
||||
this._modelLibraryTab ??= new ModelLibrarySidebarTab(this.page)
|
||||
return this._modelLibraryTab
|
||||
}
|
||||
|
||||
get nodeLibraryTab() {
|
||||
this._nodeLibraryTab ??= new NodeLibrarySidebarTab(this.page)
|
||||
return this._nodeLibraryTab
|
||||
}
|
||||
|
||||
get nodeLibraryTabV2() {
|
||||
this._nodeLibraryTabV2 ??= new NodeLibrarySidebarTabV2(this.page)
|
||||
return this._nodeLibraryTabV2
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
@@ -200,7 +214,7 @@ export class ComfyPage {
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly queue: QueueHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -248,7 +262,7 @@ export class ComfyPage {
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
this.modelLibrary = new ModelLibraryHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import { TestIds } from '../selectors'
|
||||
@@ -99,6 +100,59 @@ export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
}
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search...')
|
||||
}
|
||||
|
||||
get sidebarContent() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
return this.sidebarContent.getByRole('tab', { name, exact: true })
|
||||
}
|
||||
|
||||
get allTab() {
|
||||
return this.getTab('All')
|
||||
}
|
||||
|
||||
get blueprintsTab() {
|
||||
return this.getTab('Blueprints')
|
||||
}
|
||||
|
||||
get sortButton() {
|
||||
return this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
}
|
||||
|
||||
getFolder(folderName: string) {
|
||||
return this.sidebarContent
|
||||
.getByRole('treeitem', { name: folderName })
|
||||
.first()
|
||||
}
|
||||
|
||||
getNode(nodeName: string) {
|
||||
return this.sidebarContent.getByRole('treeitem', { name: nodeName }).first()
|
||||
}
|
||||
|
||||
async expandFolder(folderName: string) {
|
||||
const folder = this.getFolder(folderName)
|
||||
const isExpanded = await folder.getAttribute('aria-expanded')
|
||||
if (isExpanded !== 'true') {
|
||||
await folder.click()
|
||||
}
|
||||
}
|
||||
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.searchInput.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkflowsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
@@ -169,11 +223,66 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'model-library')
|
||||
}
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Models...')
|
||||
}
|
||||
|
||||
get modelTree() {
|
||||
return this.page.locator('.model-lib-tree-explorer')
|
||||
}
|
||||
|
||||
get refreshButton() {
|
||||
return this.page.getByRole('button', { name: 'Refresh' })
|
||||
}
|
||||
|
||||
get loadAllFoldersButton() {
|
||||
return this.page.getByRole('button', { name: 'Load All Folders' })
|
||||
}
|
||||
|
||||
get folderNodes() {
|
||||
return this.modelTree.locator('.p-tree-node:not(.p-tree-node-leaf)')
|
||||
}
|
||||
|
||||
get leafNodes() {
|
||||
return this.modelTree.locator('.p-tree-node-leaf')
|
||||
}
|
||||
|
||||
get modelPreview() {
|
||||
return this.page.locator('.model-lib-model-preview')
|
||||
}
|
||||
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.modelTree.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
getFolderByLabel(label: string) {
|
||||
return this.modelTree
|
||||
.locator('.p-tree-node:not(.p-tree-node-leaf)')
|
||||
.filter({ hasText: label })
|
||||
.first()
|
||||
}
|
||||
|
||||
getLeafByLabel(label: string) {
|
||||
return this.modelTree
|
||||
.locator('.p-tree-node-leaf')
|
||||
.filter({ hasText: label })
|
||||
.first()
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
@@ -182,6 +291,8 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
// --- Empty state ---
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
@@ -192,8 +303,169 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
}
|
||||
|
||||
get settingsButton() {
|
||||
return this.page.getByRole('button', { name: 'View settings' })
|
||||
}
|
||||
|
||||
// --- View mode ---
|
||||
|
||||
get listViewOption() {
|
||||
return this.page.getByText('List view')
|
||||
}
|
||||
|
||||
get gridViewOption() {
|
||||
return this.page.getByText('Grid view')
|
||||
}
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
|
||||
get sortNewestFirst() {
|
||||
return this.page.getByText('Newest first')
|
||||
}
|
||||
|
||||
get sortOldestFirst() {
|
||||
return this.page.getByText('Oldest first')
|
||||
}
|
||||
|
||||
// --- Asset cards ---
|
||||
|
||||
get assetCards() {
|
||||
return this.page.locator('[role="button"][data-selected]')
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
}
|
||||
|
||||
get selectedCards() {
|
||||
return this.page.locator('[data-selected="true"]')
|
||||
}
|
||||
|
||||
// --- List view items ---
|
||||
|
||||
get listViewItems() {
|
||||
return this.page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
}
|
||||
|
||||
// --- Selection footer ---
|
||||
|
||||
get selectionFooter() {
|
||||
return this.page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.page.getByText(/Assets Selected: \d+/)
|
||||
}
|
||||
|
||||
get deselectAllButton() {
|
||||
return this.page.getByText('Deselect all')
|
||||
}
|
||||
|
||||
get deleteSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
get downloadSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
// --- Folder view ---
|
||||
|
||||
get backToAssetsButton() {
|
||||
return this.page.getByText('Back to all assets')
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/** Dismiss all visible toast notifications by clicking their close buttons. */
|
||||
async dismissToasts() {
|
||||
const closeButtons = this.page.locator('.p-toast-close-button')
|
||||
for (const btn of await closeButtons.all()) {
|
||||
await btn.click({ force: true }).catch(() => {})
|
||||
}
|
||||
// Wait for all toast elements to fully animate out and detach from DOM
|
||||
await expect(this.page.locator('.p-toast-message'))
|
||||
.toHaveCount(0, { timeout: 5000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async switchToImported() {
|
||||
await this.dismissToasts()
|
||||
await this.importedTab.click()
|
||||
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async switchToGenerated() {
|
||||
await this.dismissToasts()
|
||||
await this.generatedTab.click()
|
||||
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async openSettingsMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.settingsButton.click()
|
||||
// Wait for popover content to render
|
||||
await this.listViewOption
|
||||
.or(this.gridViewOption)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
await this.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async waitForAssets(count?: number) {
|
||||
if (count !== undefined) {
|
||||
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
|
||||
} else {
|
||||
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,15 +50,30 @@ export class Topbar {
|
||||
return classes ? !classes.includes('invisible') : false
|
||||
}
|
||||
|
||||
get newWorkflowButton(): Locator {
|
||||
return this.page.locator('.new-blank-workflow-button')
|
||||
}
|
||||
|
||||
getWorkflowTab(tabName: string): Locator {
|
||||
return this.page
|
||||
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
|
||||
.locator('..')
|
||||
}
|
||||
|
||||
getTab(index: number): Locator {
|
||||
return this.page.locator('.workflow-tabs .p-togglebutton').nth(index)
|
||||
}
|
||||
|
||||
getActiveTab(): Locator {
|
||||
return this.page.locator(
|
||||
'.workflow-tabs .p-togglebutton.p-togglebutton-checked'
|
||||
)
|
||||
}
|
||||
|
||||
async closeWorkflowTab(tabName: string) {
|
||||
const tab = this.getWorkflowTab(tabName)
|
||||
await tab.getByRole('button', { name: 'Close' }).click({ force: true })
|
||||
await tab.hover()
|
||||
await tab.locator('.close-button').click({ force: true })
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
|
||||
@@ -1,10 +1,68 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const now = Date.now()
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<RawJobListItem>
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now()
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60_000,
|
||||
execution_start_time: now - i * 60_000,
|
||||
execution_end_time: now - i * 60_000 + 5000 + i * 1000,
|
||||
preview_output: {
|
||||
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
@@ -86,18 +144,23 @@ export class AssetsHelper {
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
})
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ export class BuilderFooterHelper {
|
||||
return this.page.getByTestId(TestIds.builder.saveButton)
|
||||
}
|
||||
|
||||
get saveGroup(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveGroup)
|
||||
}
|
||||
|
||||
get saveAsButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
}
|
||||
|
||||
134
browser_tests/fixtures/helpers/ModelLibraryHelper.ts
Normal file
134
browser_tests/fixtures/helpers/ModelLibraryHelper.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
ModelFile,
|
||||
ModelFolderInfo
|
||||
} from '../../../src/platform/assets/schemas/assetSchema'
|
||||
|
||||
const modelFoldersRoutePattern = /\/api\/experiment\/models$/
|
||||
const modelFilesRoutePattern = /\/api\/experiment\/models\/([^?]+)/
|
||||
const viewMetadataRoutePattern = /\/api\/view_metadata\/([^?]+)/
|
||||
|
||||
export interface MockModelMetadata {
|
||||
'modelspec.title'?: string
|
||||
'modelspec.author'?: string
|
||||
'modelspec.architecture'?: string
|
||||
'modelspec.description'?: string
|
||||
'modelspec.resolution'?: string
|
||||
'modelspec.tags'?: string
|
||||
}
|
||||
|
||||
export function createMockModelFolders(names: string[]): ModelFolderInfo[] {
|
||||
return names.map((name) => ({ name, folders: [] }))
|
||||
}
|
||||
|
||||
export function createMockModelFiles(
|
||||
filenames: string[],
|
||||
pathIndex = 0
|
||||
): ModelFile[] {
|
||||
return filenames.map((name) => ({ name, pathIndex }))
|
||||
}
|
||||
|
||||
export class ModelLibraryHelper {
|
||||
private foldersRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private filesRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private metadataRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private folders: ModelFolderInfo[] = []
|
||||
private filesByFolder: Record<string, ModelFile[]> = {}
|
||||
private metadataByModel: Record<string, MockModelMetadata> = {}
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockModelFolders(folders: ModelFolderInfo[]): Promise<void> {
|
||||
this.folders = [...folders]
|
||||
|
||||
if (this.foldersRouteHandler) return
|
||||
|
||||
this.foldersRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.folders)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(modelFoldersRoutePattern, this.foldersRouteHandler)
|
||||
}
|
||||
|
||||
async mockModelFiles(folder: string, files: ModelFile[]): Promise<void> {
|
||||
this.filesByFolder[folder] = [...files]
|
||||
|
||||
if (this.filesRouteHandler) return
|
||||
|
||||
this.filesRouteHandler = async (route: Route) => {
|
||||
const match = route.request().url().match(modelFilesRoutePattern)
|
||||
const folderName = match?.[1] ? decodeURIComponent(match[1]) : undefined
|
||||
const files = folderName ? (this.filesByFolder[folderName] ?? []) : []
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(files)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(modelFilesRoutePattern, this.filesRouteHandler)
|
||||
}
|
||||
|
||||
async mockMetadata(
|
||||
entries: Record<string, MockModelMetadata>
|
||||
): Promise<void> {
|
||||
Object.assign(this.metadataByModel, entries)
|
||||
|
||||
if (this.metadataRouteHandler) return
|
||||
|
||||
this.metadataRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const filename = url.searchParams.get('filename') ?? ''
|
||||
const metadata = this.metadataByModel[filename]
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(metadata ?? {})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(viewMetadataRoutePattern, this.metadataRouteHandler)
|
||||
}
|
||||
|
||||
async mockFoldersWithFiles(config: Record<string, string[]>): Promise<void> {
|
||||
const folderNames = Object.keys(config)
|
||||
await this.mockModelFolders(createMockModelFolders(folderNames))
|
||||
for (const [folder, files] of Object.entries(config)) {
|
||||
await this.mockModelFiles(folder, createMockModelFiles(files))
|
||||
}
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.folders = []
|
||||
this.filesByFolder = {}
|
||||
this.metadataByModel = {}
|
||||
|
||||
if (this.foldersRouteHandler) {
|
||||
await this.page.unroute(
|
||||
modelFoldersRoutePattern,
|
||||
this.foldersRouteHandler
|
||||
)
|
||||
this.foldersRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.filesRouteHandler) {
|
||||
await this.page.unroute(modelFilesRoutePattern, this.filesRouteHandler)
|
||||
this.filesRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.metadataRouteHandler) {
|
||||
await this.page.unroute(
|
||||
viewMetadataRoutePattern,
|
||||
this.metadataRouteHandler
|
||||
)
|
||||
this.metadataRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
export class QueueHelper {
|
||||
private queueRouteHandler: ((route: Route) => void) | null = null
|
||||
private historyRouteHandler: ((route: Route) => void) | null = null
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Mock the /api/queue endpoint to return specific queue state.
|
||||
*/
|
||||
async mockQueueState(
|
||||
running: number = 0,
|
||||
pending: number = 0
|
||||
): Promise<void> {
|
||||
this.queueRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
queue_running: Array.from({ length: running }, (_, i) => [
|
||||
i,
|
||||
`running-${i}`,
|
||||
{},
|
||||
{},
|
||||
[]
|
||||
]),
|
||||
queue_pending: Array.from({ length: pending }, (_, i) => [
|
||||
i,
|
||||
`pending-${i}`,
|
||||
{},
|
||||
{},
|
||||
[]
|
||||
])
|
||||
})
|
||||
})
|
||||
await this.page.route('**/api/queue', this.queueRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the /api/history endpoint with completed/failed job entries.
|
||||
*/
|
||||
async mockHistory(
|
||||
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
|
||||
): Promise<void> {
|
||||
const history: Record<string, unknown> = {}
|
||||
for (const job of jobs) {
|
||||
history[job.promptId] = {
|
||||
prompt: [0, job.promptId, {}, {}, []],
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: job.status === 'success' ? 'success' : 'error',
|
||||
completed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
this.historyRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(history)
|
||||
})
|
||||
await this.page.route('**/api/history**', this.historyRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all route mocks set by this helper.
|
||||
*/
|
||||
async clearMocks(): Promise<void> {
|
||||
if (this.queueRouteHandler) {
|
||||
await this.page.unroute('**/api/queue', this.queueRouteHandler)
|
||||
this.queueRouteHandler = null
|
||||
}
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute('**/api/history**', this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,15 @@ export const TestIds = {
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model'
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
missingMediaLibrarySelect: 'missing-media-library-select',
|
||||
missingMediaStatusCard: 'missing-media-status-card',
|
||||
missingMediaConfirmButton: 'missing-media-confirm-button',
|
||||
missingMediaCancelButton: 'missing-media-cancel-button',
|
||||
missingMediaLocateButton: 'missing-media-locate-button'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
@@ -61,8 +69,21 @@ export const TestIds = {
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel'
|
||||
},
|
||||
subgraphEditor: {
|
||||
toggle: 'subgraph-editor-toggle',
|
||||
shownSection: 'subgraph-editor-shown-section',
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
widgetToggle: 'subgraph-widget-toggle',
|
||||
widgetLabel: 'subgraph-widget-label',
|
||||
iconLink: 'icon-link',
|
||||
iconEye: 'icon-eye',
|
||||
widgetActionsMenuButton: 'widget-actions-menu-button'
|
||||
},
|
||||
node: {
|
||||
titleInput: 'node-title-input'
|
||||
titleInput: 'node-title-input',
|
||||
pinIndicator: 'node-pin-indicator',
|
||||
innerWrapper: 'node-inner-wrapper',
|
||||
mainImage: 'main-image'
|
||||
},
|
||||
selectionToolbox: {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
@@ -70,6 +91,9 @@ export const TestIds = {
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
},
|
||||
widgets: {
|
||||
container: 'node-widgets',
|
||||
widget: 'node-widget',
|
||||
@@ -82,6 +106,7 @@ export const TestIds = {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
saveAsButton: 'builder-save-as-button',
|
||||
saveGroup: 'builder-save-group',
|
||||
saveAsChevron: 'builder-save-as-chevron',
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
@@ -130,5 +155,7 @@ export type TestIdValue =
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.menu)[keyof typeof TestIds.menu]
|
||||
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
|
||||
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
98
browser_tests/fixtures/utils/slotBoundsUtil.ts
Normal file
98
browser_tests/fixtures/utils/slotBoundsUtil.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
export interface NodeSlotData {
|
||||
nodeId: string
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect slot center offsets relative to the parent node element.
|
||||
* Returns `null` when the node element is not found.
|
||||
*/
|
||||
export async function measureNodeSlotOffsets(
|
||||
page: Page,
|
||||
nodeId: string
|
||||
): Promise<NodeSlotData | null> {
|
||||
return page.evaluate((id) => {
|
||||
const nodeEl = document.querySelector(`[data-node-id="${id}"]`)
|
||||
if (!nodeEl || !(nodeEl instanceof HTMLElement)) return null
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
slots.push({
|
||||
key: (slotEl as HTMLElement).dataset.slotKey ?? 'unknown',
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: id,
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
}
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that every slot falls within the node dimensions (± `margin` px).
|
||||
*/
|
||||
export function expectSlotsWithinBounds(
|
||||
data: NodeSlotData,
|
||||
margin: number,
|
||||
label?: string
|
||||
) {
|
||||
const prefix = label ? `${label}: ` : ''
|
||||
|
||||
for (const slot of data.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`${prefix}Slot ${slot.key} X=${slot.offsetX} outside width=${data.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-margin)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`${prefix}Slot ${slot.key} X=${slot.offsetX} outside width=${data.nodeW}`
|
||||
).toBeLessThanOrEqual(data.nodeW + margin)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`${prefix}Slot ${slot.key} Y=${slot.offsetY} outside height=${data.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-margin)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`${prefix}Slot ${slot.key} Y=${slot.offsetY} outside height=${data.nodeH}`
|
||||
).toBeLessThanOrEqual(data.nodeH + margin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for slots, measure, and assert within bounds — single-node convenience.
|
||||
*/
|
||||
export async function assertNodeSlotsWithinBounds(
|
||||
page: Page,
|
||||
nodeId: string,
|
||||
margin: number = 20
|
||||
) {
|
||||
await page
|
||||
.locator(`[data-node-id="${nodeId}"] [data-slot-key]`)
|
||||
.first()
|
||||
.waitFor()
|
||||
|
||||
const data = await measureNodeSlotOffsets(page, nodeId)
|
||||
expect(data, `Node ${nodeId} not found in DOM`).not.toBeNull()
|
||||
expectSlotsWithinBounds(data!, margin, `Node ${nodeId}`)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
constructor(private readonly locator: Locator) {}
|
||||
@@ -20,6 +22,10 @@ export class VueNodeFixture {
|
||||
return this.locator.locator('[data-testid^="node-body-"]')
|
||||
}
|
||||
|
||||
get pinIndicator(): Locator {
|
||||
return this.locator.getByTestId(TestIds.node.pinIndicator)
|
||||
}
|
||||
|
||||
get collapseButton(): Locator {
|
||||
return this.locator.locator('[data-testid="node-collapse-button"]')
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Response } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
@@ -189,6 +189,41 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
await expect(saveAs.nameInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save button width is consistent across all states', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
// State 1: Disabled "Save as" (no outputs selected)
|
||||
const disabledBox = await appMode.footer.saveAsButton.boundingBox()
|
||||
expect(disabledBox).toBeTruthy()
|
||||
|
||||
// Select I/O to enable the button
|
||||
await appMode.steps.goToInputs()
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await appMode.select.selectInputWidget(ksampler)
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode()
|
||||
|
||||
// State 2: Enabled "Save as" (unsaved, has outputs)
|
||||
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
|
||||
expect(enabledBox).toBeTruthy()
|
||||
expect(enabledBox!.width).toBe(disabledBox!.width)
|
||||
|
||||
// Save the workflow to transition to the Save + chevron state
|
||||
await builderSaveAs(appMode, `${Date.now()} width-test`, 'App')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// State 3: Save + chevron button group (saved workflow)
|
||||
const saveButtonGroupBox = await appMode.footer.saveGroup.boundingBox()
|
||||
expect(saveButtonGroupBox).toBeTruthy()
|
||||
expect(saveButtonGroupBox!.width).toBe(disabledBox!.width)
|
||||
})
|
||||
|
||||
test('Connect output popover appears when no outputs selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
68
browser_tests/tests/collapsedNodeLinks.spec.ts
Normal file
68
browser_tests/tests/collapsedNodeLinks.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { assertNodeSlotsWithinBounds } from '../fixtures/utils/slotBoundsUtil'
|
||||
|
||||
const NODE_ID = '3'
|
||||
const NODE_TITLE = 'KSampler'
|
||||
|
||||
test.describe(
|
||||
'Collapsed node link positions',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('link endpoints stay within collapsed node bounds', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
|
||||
test('links follow collapsed node after drag', async ({ comfyPage }) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const box = await node.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2,
|
||||
box!.y + box!.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2 + 200,
|
||||
box!.y + box!.height / 2 + 100,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
|
||||
test('links recover correct positions after expand', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
}
|
||||
)
|
||||
225
browser_tests/tests/missingMedia.spec.ts
Normal file
225
browser_tests/tests/missingMedia.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
async function loadMissingMediaAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow = 'missing/missing_media_single'
|
||||
) {
|
||||
await comfyPage.workflow.loadWorkflow(workflow)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
}
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaUploadDropzone
|
||||
)
|
||||
const [fileChooser] = await Promise.all([
|
||||
comfyPage.page.waitForEvent('filechooser'),
|
||||
dropzone.click()
|
||||
])
|
||||
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
}
|
||||
|
||||
async function confirmPendingSelection(comfyPage: ComfyPage) {
|
||||
const confirmButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaConfirmButton
|
||||
)
|
||||
await expect(confirmButton).toBeEnabled()
|
||||
await confirmButton.click()
|
||||
}
|
||||
|
||||
function getMediaRow(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
|
||||
}
|
||||
|
||||
function getStatusCard(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
|
||||
}
|
||||
|
||||
function getDropzone(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
|
||||
}
|
||||
|
||||
test.describe('Missing media inputs in Error Tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Detection', () => {
|
||||
test('Shows error overlay when workflow has missing media inputs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/missing required inputs/i)
|
||||
})
|
||||
|
||||
test('Shows missing media group in errors tab after clicking See Errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows correct number of missing media rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_multiple'
|
||||
)
|
||||
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Shows upload dropzone and library select for each missing item', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Does not show error overlay when all media inputs exist', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Upload flow (2-step confirm)', () => {
|
||||
test('Upload via file picker shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Library select flow (2-step confirm)', () => {
|
||||
test('Selecting from library shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
const librarySelect = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLibrarySelect
|
||||
)
|
||||
await librarySelect.getByRole('combobox').click()
|
||||
|
||||
const optionCount = await comfyPage.page.getByRole('option').count()
|
||||
if (optionCount === 0) {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
await comfyPage.page.getByRole('option').first().click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cancel selection', () => {
|
||||
test('Cancelling pending selection returns to upload/library UI', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
await expect(getDropzone(comfyPage)).not.toBeVisible()
|
||||
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
|
||||
.click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).not.toBeVisible()
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('All resolved', () => {
|
||||
test('Missing Inputs group disappears when all items are resolved', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
await confirmPendingSelection(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Locate node', () => {
|
||||
test('Locate button navigates canvas to the missing media node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLocateButton
|
||||
)
|
||||
await expect(locateButton).toBeVisible()
|
||||
await locateButton.click()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
})
|
||||
.not.toEqual(offsetBefore)
|
||||
})
|
||||
})
|
||||
})
|
||||
111
browser_tests/tests/queue/queueOverlay.spec.ts
Normal file
111
browser_tests/tests/queue/queueOverlay.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
create_time: now - 60_000,
|
||||
execution_start_time: now - 60_000,
|
||||
execution_end_time: now - 50_000,
|
||||
outputs_count: 2
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-completed-2',
|
||||
status: 'completed',
|
||||
create_time: now - 120_000,
|
||||
execution_start_time: now - 120_000,
|
||||
execution_end_time: now - 115_000,
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-failed-1',
|
||||
status: 'failed',
|
||||
create_time: now - 30_000,
|
||||
execution_start_time: now - 30_000,
|
||||
execution_end_time: now - 28_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
// Expanded overlay should show job items
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Overlay shows filter tabs (All, Completed)', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: 'All', exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: 'Completed', exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Overlay shows Failed tab when failed jobs exist', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: 'Failed', exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Completed filter shows only completed jobs', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Completed', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-completed-1"]')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="job-failed-1"]')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Toggling overlay again closes it', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
|
||||
|
||||
await toggle.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id]').first()
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,20 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
|
||||
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
|
||||
return comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: nodeTitle })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
}
|
||||
|
||||
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
const nodePos = await nodeRef.getPosition()
|
||||
@@ -36,7 +47,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
() => window.app!.graph!._nodes.length
|
||||
)
|
||||
|
||||
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
|
||||
const deleteButton = comfyPage.page.getByTestId('delete-button')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
@@ -51,14 +62,12 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const infoButton = comfyPage.page.locator('[data-testid="info-button"]')
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="properties-panel"]')
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button visible with multi-select', async ({
|
||||
@@ -71,7 +80,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="convert-to-subgraph-button"]')
|
||||
comfyPage.page.getByTestId('convert-to-subgraph-button')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -88,7 +97,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
() => window.app!.graph!._nodes.length
|
||||
)
|
||||
|
||||
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
|
||||
const deleteButton = comfyPage.page.getByTestId('delete-button')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
@@ -98,4 +107,152 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
)
|
||||
expect(newCount).toBe(initialCount - 2)
|
||||
})
|
||||
|
||||
test('bypass button toggles bypass on single node', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(false)
|
||||
|
||||
const bypassButton = comfyPage.page.getByTestId('bypass-button')
|
||||
await expect(bypassButton).toBeVisible()
|
||||
await bypassButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, 'KSampler')).toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
|
||||
await bypassButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, 'KSampler')).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button converts node to subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const convertButton = comfyPage.page.getByTestId(
|
||||
'convert-to-subgraph-button'
|
||||
)
|
||||
await expect(convertButton).toBeVisible()
|
||||
await convertButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// KSampler should be gone, replaced by a subgraph node
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))
|
||||
.toHaveLength(0)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph'))
|
||||
.toHaveLength(1)
|
||||
})
|
||||
|
||||
test('convert-to-subgraph button converts multiple nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const convertButton = comfyPage.page.getByTestId(
|
||||
'convert-to-subgraph-button'
|
||||
)
|
||||
await expect(convertButton).toBeVisible()
|
||||
await convertButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph'))
|
||||
.toHaveLength(1)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('frame nodes button creates group from multiple selected nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialGroupCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.groups.length
|
||||
)
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const frameButton = comfyPage.page.getByRole('button', {
|
||||
name: /Frame Nodes/i
|
||||
})
|
||||
await expect(frameButton).toBeVisible()
|
||||
await frameButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => window.app!.graph.groups.length)
|
||||
)
|
||||
.toBe(initialGroupCount + 1)
|
||||
})
|
||||
|
||||
test('frame nodes button is not visible for single selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const frameButton = comfyPage.page.getByRole('button', {
|
||||
name: /Frame Nodes/i
|
||||
})
|
||||
await expect(frameButton).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('execute button visible when output node selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select the SaveImage node by panning to it
|
||||
const saveImageRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('Save Image')
|
||||
)[0]
|
||||
await selectNodeWithPan(comfyPage, saveImageRef)
|
||||
|
||||
const executeButton = comfyPage.page.getByRole('button', {
|
||||
name: /Execute to selected output nodes/i
|
||||
})
|
||||
await expect(executeButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('execute button not visible when non-output node selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const executeButton = comfyPage.page.getByRole('button', {
|
||||
name: /Execute to selected output nodes/i
|
||||
})
|
||||
await expect(executeButton).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,72 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '../../fixtures/helpers/AssetsHelper'
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
test.describe('Assets sidebar', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1000,
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 1010,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-beta',
|
||||
create_time: 2000,
|
||||
execution_start_time: 2000,
|
||||
execution_end_time: 2003,
|
||||
preview_output: {
|
||||
filename: 'portrait.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gamma',
|
||||
create_time: 3000,
|
||||
execution_start_time: 3000,
|
||||
execution_end_time: 3020,
|
||||
preview_output: {
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 2
|
||||
})
|
||||
]
|
||||
|
||||
const SAMPLE_IMPORTED_FILES = [
|
||||
'reference_photo.png',
|
||||
'background.jpg',
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - empty states', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockEmptyState()
|
||||
await comfyPage.setup()
|
||||
@@ -12,19 +76,603 @@ test.describe('Assets sidebar', () => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for generated and imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
await tab.importedTab.click()
|
||||
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Tab navigation
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - tab navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Generated tab is active by default', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
test('Can switch between Generated and Imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('Search is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Asset display - grid view
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - grid view display', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays generated assets as cards in grid view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.waitForAssets()
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Displays imported files when switching to Imported tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. View mode toggle (grid <-> list)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - view mode toggle', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Can switch back to grid view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Search functionality
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Search input is visible', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.searchInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Filtering assets by search query reduces displayed count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect(async () => {
|
||||
const filteredCount = await tab.assetCards.count()
|
||||
expect(filteredCount).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Clearing search restores all assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(async () => {
|
||||
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await tab.searchInput.fill('')
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_file_xyz')
|
||||
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 6. Asset selection
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking an asset card selects it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Selection shows footer with count and actions', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Deselect all clears selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 7. Context menu
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - context menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Context menu contains Download action for output asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Download')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Inspect action for image assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Inspect asset')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Delete action for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Delete')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Copy job ID for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Copy job ID')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains workflow actions for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await expect(
|
||||
tab.contextMenuItem('Open as workflow in new tab')
|
||||
).toBeVisible()
|
||||
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bulk context menu shows when multiple assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
|
||||
await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 8. Bulk actions (footer)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - bulk actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Footer shows download button when assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Footer shows delete button when output assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Selection count displays correct number', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select two assets
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
await cards.first().click()
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
const text = await tab.selectionCountButton.textContent()
|
||||
expect(text).toMatch(/Assets Selected: \d+/)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - pagination', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('initial load fetches first batch with offset 0', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const manyJobs = createMockJobs(250)
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Capture the first history fetch (terminal statuses only).
|
||||
// Queue polling also hits /jobs but with status=in_progress,pending.
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
const status = url.searchParams.get('status') ?? ''
|
||||
return status.includes('completed')
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const req = await firstRequest
|
||||
const url = new URL(req.url())
|
||||
expect(url.searchParams.get('offset')).toBe('0')
|
||||
expect(Number(url.searchParams.get('limit'))).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 10. Settings menu visibility
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - settings menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Settings menu shows view mode options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.listViewOption).toBeVisible()
|
||||
await expect(tab.gridViewOption).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
244
browser_tests/tests/sidebar/modelLibrary.spec.ts
Normal file
244
browser_tests/tests/sidebar/modelLibrary.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
const MOCK_FOLDERS: Record<string, string[]> = {
|
||||
checkpoints: [
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVision_v51.safetensors'
|
||||
],
|
||||
loras: ['detail_tweaker_xl.safetensors', 'add_brightness.safetensors'],
|
||||
vae: ['sdxl_vae.safetensors']
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Tab open/close
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Opens model library tab and shows tree', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.modelTree).toBeVisible()
|
||||
await expect(tab.searchInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows refresh and load all folders buttons', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.refreshButton).toBeVisible()
|
||||
await expect(tab.loadAllFoldersButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Folder display
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - folders', () => {
|
||||
// Mocks are set up before setup(), so app.ts's loadModelFolders()
|
||||
// call during initialization hits the mock and populates the store.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays model folders after opening tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('vae')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Expanding a folder loads and shows models', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
// Click the folder to expand it
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
|
||||
// Models should appear as leaf nodes
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('realisticVision_v51')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Expanding a different folder shows its models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.getFolderByLabel('loras').click()
|
||||
|
||||
await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getLeafByLabel('add_brightness')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Search
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Search filters models by filename', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('dreamshaper')
|
||||
|
||||
// Wait for debounce (300ms) + load + render
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Other models should not be visible
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Clearing search restores folder view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('dreamshaper')
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Clear the search
|
||||
await tab.searchInput.fill('')
|
||||
|
||||
// Folders should be visible again (collapsed)
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty tree', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_model_xyz')
|
||||
|
||||
// Wait for debounce, then verify no leaf nodes
|
||||
await expect
|
||||
.poll(async () => await tab.leafNodes.count(), { timeout: 5000 })
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. Refresh and load all
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - refresh', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Refresh button reloads folder list', async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({
|
||||
checkpoints: ['model_a.safetensors']
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
|
||||
// Update mock to include a new folder
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({
|
||||
checkpoints: ['model_a.safetensors'],
|
||||
loras: ['lora_b.safetensors']
|
||||
})
|
||||
|
||||
// Wait for the refresh request to complete
|
||||
const refreshRequest = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().endsWith('/experiment/models'),
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
await tab.refreshButton.click()
|
||||
await refreshRequest
|
||||
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Load all folders button triggers loading all model data', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
// Wait for a per-folder model files request triggered by load all
|
||||
const folderRequest = comfyPage.page.waitForRequest(
|
||||
(req) =>
|
||||
/\/api\/experiment\/models\/[^/]+$/.test(req.url()) &&
|
||||
req.method() === 'GET',
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
await tab.loadAllFoldersButton.click()
|
||||
await folderRequest
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Empty state
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - empty state', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty tree when no model folders exist', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.modelTree).toBeVisible()
|
||||
expect(await tab.folderNodes.count()).toBe(0)
|
||||
expect(await tab.leafNodes.count()).toBe(0)
|
||||
})
|
||||
})
|
||||
126
browser_tests/tests/sidebar/nodeLibraryV2.spec.ts
Normal file
126
browser_tests/tests/sidebar/nodeLibraryV2.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Node library sidebar V2', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.open()
|
||||
})
|
||||
|
||||
test('Can switch between tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
await tab.blueprintsTab.click()
|
||||
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
await tab.allTab.click()
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
test('All tab displays node tree with folders', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.getFolder('sampling')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can expand folder and see nodes in All tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await tab.expandFolder('sampling')
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search filters nodes in All tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await expect(tab.getNode('KSampler (Advanced)')).not.toBeVisible()
|
||||
|
||||
await tab.searchInput.fill('KSampler')
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getNode('CLIPLoader')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Drag node to canvas adds it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await tab.expandFolder('sampling')
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
const canvasBoundingBox = await comfyPage.page
|
||||
.locator('#graph-canvas')
|
||||
.boundingBox()
|
||||
expect(canvasBoundingBox).not.toBeNull()
|
||||
const targetPosition = {
|
||||
x: canvasBoundingBox!.x + canvasBoundingBox!.width / 2,
|
||||
y: canvasBoundingBox!.y + canvasBoundingBox!.height / 2
|
||||
}
|
||||
|
||||
const nodeLocator = tab.getNode('KSampler (Advanced)')
|
||||
await nodeLocator.dragTo(comfyPage.page.locator('#graph-canvas'), {
|
||||
targetPosition
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 5000 })
|
||||
.toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Right-click node shows context menu with bookmark option', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await tab.expandFolder('sampling')
|
||||
const node = tab.getNode('KSampler (Advanced)')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await node.click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.getByRole('menuitem', {
|
||||
name: /Bookmark Node/
|
||||
})
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Search clear restores folder view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await expect(tab.getFolder('sampling')).toBeVisible()
|
||||
|
||||
await tab.searchInput.fill('KSampler')
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
await tab.searchInput.clear()
|
||||
await tab.searchInput.press('Enter')
|
||||
|
||||
await expect(tab.getFolder('sampling')).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Sort dropdown shows sorting options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await tab.sortButton.click()
|
||||
|
||||
// Reka UI DropdownMenuRadioItem renders with role="menuitemradio"
|
||||
const options = comfyPage.page.getByRole('menuitemradio')
|
||||
await expect(options.first()).toBeVisible({ timeout: 3000 })
|
||||
await expect
|
||||
.poll(() => options.count(), { timeout: 3000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
65
browser_tests/tests/sidebar/workflowSearch.spec.ts
Normal file
65
browser_tests/tests/sidebar/workflowSearch.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/** Locate a workflow label in whatever panel is visible (browse or search). */
|
||||
function findWorkflow(page: Page, name: string) {
|
||||
return page
|
||||
.getByTestId(TestIds.sidebar.workflows)
|
||||
.locator('.node-label', { hasText: name })
|
||||
}
|
||||
|
||||
test.describe('Workflow sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({
|
||||
'alpha-workflow.json': 'default.json',
|
||||
'beta-workflow.json': 'default.json'
|
||||
})
|
||||
})
|
||||
|
||||
test('Search filters saved workflows by name', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('alpha')
|
||||
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'beta-workflow')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Clearing search restores all workflows', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('alpha')
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'beta-workflow')
|
||||
).not.toBeVisible()
|
||||
|
||||
await searchInput.fill('')
|
||||
|
||||
await expect(tab.getPersistedItem('alpha-workflow')).toBeVisible()
|
||||
await expect(tab.getPersistedItem('beta-workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty results', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('nonexistent_xyz')
|
||||
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'alpha-workflow')
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'beta-workflow')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,10 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
import {
|
||||
expectSlotsWithinBounds,
|
||||
measureNodeSlotOffsets
|
||||
} from '../../fixtures/utils/slotBoundsUtil'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
@@ -19,20 +23,6 @@ const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
isSubgraph: boolean
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -604,71 +594,19 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for slot elements to appear in DOM
|
||||
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
|
||||
|
||||
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph._nodes
|
||||
const slotData: NodeSlotData[] = []
|
||||
const nodeIds = await comfyPage.page.evaluate(() =>
|
||||
window
|
||||
.app!.graph._nodes.filter((n) => !!n.isSubgraphNode?.())
|
||||
.map((n) => String(n.id))
|
||||
)
|
||||
expect(nodeIds.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeId = String(node.id)
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
) as HTMLElement | null
|
||||
if (!nodeEl) continue
|
||||
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
if (slotEls.length === 0) continue
|
||||
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
|
||||
slots.push({
|
||||
key: slotKey,
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
slotData.push({
|
||||
nodeId,
|
||||
isSubgraph: !!node.isSubgraphNode?.(),
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
return slotData
|
||||
})
|
||||
|
||||
const subgraphNodes = result.filter((n) => n.isSubgraph)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of subgraphNodes) {
|
||||
for (const slot of node.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
|
||||
}
|
||||
for (const nodeId of nodeIds) {
|
||||
const data = await measureNodeSlotOffsets(comfyPage.page, nodeId)
|
||||
expect(data, `Node ${nodeId} not found in DOM`).not.toBeNull()
|
||||
expectSlotsWithinBounds(data!, SLOT_BOUNDS_MARGIN, `Node ${nodeId}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
148
browser_tests/tests/subgraphPromotedWidgetPanel.spec.ts
Normal file
148
browser_tests/tests/subgraphPromotedWidgetPanel.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
async function ensurePropertiesPanel(comfyPage: ComfyPage) {
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
if (!(await panel.isVisible())) {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
}
|
||||
await expect(panel).toBeVisible()
|
||||
return panel
|
||||
}
|
||||
|
||||
async function selectSubgraphAndOpenEditor(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
) {
|
||||
const subgraphNodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
await subgraphNodes[0].click('title')
|
||||
|
||||
await ensurePropertiesPanel(comfyPage)
|
||||
|
||||
const editorToggle = comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle)
|
||||
await expect(editorToggle).toBeVisible()
|
||||
await editorToggle.click()
|
||||
|
||||
const shownSection = comfyPage.page.getByTestId(
|
||||
TestIds.subgraphEditor.shownSection
|
||||
)
|
||||
await expect(shownSection).toBeVisible()
|
||||
return shownSection
|
||||
}
|
||||
|
||||
async function collectWidgetLabels(shownSection: Locator) {
|
||||
const labels = shownSection.getByTestId(TestIds.subgraphEditor.widgetLabel)
|
||||
const texts = await labels.allTextContents()
|
||||
return texts.map((t) => t.trim())
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget panel',
|
||||
{ tag: ['@node', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.describe('SubgraphEditor (Settings panel)', () => {
|
||||
test('linked promoted widgets have hide toggle disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
const shownSection = await selectSubgraphAndOpenEditor(
|
||||
comfyPage,
|
||||
'Sub 0'
|
||||
)
|
||||
|
||||
const toggleButtons = shownSection.getByTestId(
|
||||
TestIds.subgraphEditor.widgetToggle
|
||||
)
|
||||
await expect(toggleButtons.first()).toBeVisible()
|
||||
|
||||
const count = await toggleButtons.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(toggleButtons.nth(i)).toBeDisabled()
|
||||
}
|
||||
})
|
||||
|
||||
test('linked promoted widgets show link icon instead of eye icon', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
const shownSection = await selectSubgraphAndOpenEditor(
|
||||
comfyPage,
|
||||
'Sub 0'
|
||||
)
|
||||
|
||||
const linkIcons = shownSection.getByTestId(
|
||||
TestIds.subgraphEditor.iconLink
|
||||
)
|
||||
await expect(linkIcons.first()).toBeVisible()
|
||||
|
||||
const eyeIcons = shownSection.getByTestId(
|
||||
TestIds.subgraphEditor.iconEye
|
||||
)
|
||||
await expect(eyeIcons).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('widget labels display renamed values instead of raw names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/test-values-input-subgraph'
|
||||
)
|
||||
const shownSection = await selectSubgraphAndOpenEditor(
|
||||
comfyPage,
|
||||
'Input Test Subgraph'
|
||||
)
|
||||
|
||||
const allTexts = await collectWidgetLabels(shownSection)
|
||||
expect(allTexts.length).toBeGreaterThan(0)
|
||||
|
||||
// The fixture has a widget with name="text" but
|
||||
// label="renamed_from_sidepanel". The panel should show the
|
||||
// renamed label, not the raw widget name.
|
||||
expect(allTexts).toContain('renamed_from_sidepanel')
|
||||
expect(allTexts).not.toContain('text')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Parameters tab (WidgetActions menu)', () => {
|
||||
test('linked promoted widget menu should not show Hide/Show input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('Sub 0')
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
await subgraphNodes[0].click('title')
|
||||
|
||||
const panel = await ensurePropertiesPanel(comfyPage)
|
||||
|
||||
const moreButtons = panel.getByTestId(
|
||||
TestIds.subgraphEditor.widgetActionsMenuButton
|
||||
)
|
||||
await expect(moreButtons.first()).toBeVisible()
|
||||
await moreButtons.first().click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(menu.getByText('Hide input')).toHaveCount(0)
|
||||
await expect(menu.getByText('Show input')).toHaveCount(0)
|
||||
await expect(menu.getByText('Rename')).toBeVisible()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
154
browser_tests/tests/topbar/workflowTabs.spec.ts
Normal file
154
browser_tests/tests/topbar/workflowTabs.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Workflow tabs', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Default workflow tab is visible on load', async ({ comfyPage }) => {
|
||||
const tabNames = await comfyPage.menu.topbar.getTabNames()
|
||||
expect(tabNames.length).toBe(1)
|
||||
expect(tabNames[0]).toContain('Unsaved Workflow')
|
||||
})
|
||||
|
||||
test('Creating a new workflow adds a tab', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
expect(await topbar.getTabNames()).toHaveLength(1)
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
const tabNames = await topbar.getTabNames()
|
||||
expect(tabNames[1]).toContain('Unsaved Workflow (2)')
|
||||
})
|
||||
|
||||
test('Switching tabs changes active workflow', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
const activeNameBefore = await topbar.getActiveTabName()
|
||||
expect(activeNameBefore).toContain('Unsaved Workflow (2)')
|
||||
|
||||
await topbar.getTab(0).click()
|
||||
await expect
|
||||
.poll(() => topbar.getActiveTabName())
|
||||
.toContain('Unsaved Workflow')
|
||||
|
||||
const activeAfter = await topbar.getActiveTabName()
|
||||
expect(activeAfter).not.toContain('(2)')
|
||||
})
|
||||
|
||||
test('Closing a tab removes it', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
|
||||
const remaining = await topbar.getTabNames()
|
||||
expect(remaining[0]).toContain('Unsaved Workflow')
|
||||
})
|
||||
|
||||
test('Right-clicking a tab shows context menu', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.getTab(0).click({ button: 'right' })
|
||||
|
||||
// Reka UI ContextMenuContent gets data-state="open" when active
|
||||
const contextMenu = comfyPage.page.locator(
|
||||
'[role="menu"][data-state="open"]'
|
||||
)
|
||||
await expect(contextMenu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await expect(
|
||||
contextMenu.getByRole('menuitem', { name: /Close Tab/i }).first()
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
contextMenu.getByRole('menuitem', { name: /Save/i }).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu Close Tab action removes the tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await topbar.getTab(1).click({ button: 'right' })
|
||||
const contextMenu = comfyPage.page.locator(
|
||||
'[role="menu"][data-state="open"]'
|
||||
)
|
||||
await expect(contextMenu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await contextMenu
|
||||
.getByRole('menuitem', { name: /Close Tab/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('Closing the last tab creates a new default workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow')
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
|
||||
const tabNames = await topbar.getTabNames()
|
||||
expect(tabNames[0]).toContain('Unsaved Workflow')
|
||||
})
|
||||
|
||||
test('Modified workflow shows unsaved indicator', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
// Modify the graph via litegraph API to trigger unsaved state
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app?.graph
|
||||
const node = window.LiteGraph?.createNode('Note')
|
||||
if (graph && node) graph.add(node)
|
||||
})
|
||||
|
||||
// WorkflowTab renders "•" when the workflow has unsaved changes
|
||||
const activeTab = topbar.getActiveTab()
|
||||
await expect(activeTab.locator('text=•')).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Multiple tabs can be created, switched, and closed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
// Create 2 additional tabs (3 total)
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
|
||||
|
||||
// Switch to first tab
|
||||
await topbar.getTab(0).click()
|
||||
await expect
|
||||
.poll(() => topbar.getActiveTabName())
|
||||
.toContain('Unsaved Workflow')
|
||||
|
||||
// Close the middle tab
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,512 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../../fixtures/selectors'
|
||||
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
|
||||
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
|
||||
await comfyPage.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
|
||||
await fixture.header.click()
|
||||
await fixture.header.click({ button: 'right' })
|
||||
const menu = comfyPage.contextMenu.primeVueMenu
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
return menu
|
||||
}
|
||||
|
||||
async function openMultiNodeContextMenu(
|
||||
comfyPage: ComfyPage,
|
||||
titles: string[]
|
||||
) {
|
||||
// deselectAll via evaluate — clearSelection() clicks at a fixed position
|
||||
// which can hit nodes or the toolbar overlay
|
||||
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
for (const title of titles) {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await fixture.header.click({ modifiers: ['ControlOrMeta'] })
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstFixture = await comfyPage.vueNodes.getFixtureByTitle(titles[0])
|
||||
const box = await firstFixture.header.boundingBox()
|
||||
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
|
||||
await comfyPage.page.mouse.click(
|
||||
box.x + box.width / 2,
|
||||
box.y + box.height / 2,
|
||||
{ button: 'right' }
|
||||
)
|
||||
|
||||
const menu = comfyPage.contextMenu.primeVueMenu
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
return menu
|
||||
}
|
||||
|
||||
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
|
||||
return comfyPage.vueNodes
|
||||
.getNodeByTitle(nodeTitle)
|
||||
.getByTestId(TestIds.node.innerWrapper)
|
||||
}
|
||||
|
||||
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
|
||||
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
return refs[0]
|
||||
}
|
||||
|
||||
test.describe('Vue Node Context Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.describe('Single Node Actions', () => {
|
||||
test('should rename node via context menu', async ({ comfyPage }) => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Rename')
|
||||
|
||||
const titleInput = comfyPage.page.getByTestId(TestIds.node.titleInput)
|
||||
await titleInput.waitFor({ state: 'visible' })
|
||||
await titleInput.fill('My Renamed Sampler')
|
||||
await titleInput.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const renamedNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('My Renamed Sampler')
|
||||
await expect(renamedNode).toBeVisible()
|
||||
})
|
||||
|
||||
test('should copy and paste node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openContextMenu(comfyPage, 'Load Checkpoint')
|
||||
await clickExactMenuItem(comfyPage, 'Copy')
|
||||
|
||||
// Internal clipboard paste (menu Copy uses canvas clipboard, not OS)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + 1
|
||||
)
|
||||
})
|
||||
|
||||
test('should duplicate node via context menu', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openContextMenu(comfyPage, 'Load Checkpoint')
|
||||
await clickExactMenuItem(comfyPage, 'Duplicate')
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + 1
|
||||
)
|
||||
})
|
||||
|
||||
test('should pin and unpin node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeTitle = 'Load Checkpoint'
|
||||
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
||||
|
||||
// Pin via context menu
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Pin')
|
||||
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
|
||||
await expect(fixture.pinIndicator).toBeVisible()
|
||||
expect(await nodeRef.isPinned()).toBe(true)
|
||||
|
||||
// Verify drag blocked
|
||||
const header = fixture.header
|
||||
const posBeforeDrag = await header.boundingBox()
|
||||
if (!posBeforeDrag) throw new Error('Header not found')
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: posBeforeDrag.x + 10, y: posBeforeDrag.y + 10 },
|
||||
{ x: posBeforeDrag.x + 256, y: posBeforeDrag.y + 256 }
|
||||
)
|
||||
const posAfterDrag = await header.boundingBox()
|
||||
expect(posAfterDrag).toEqual(posBeforeDrag)
|
||||
|
||||
// Unpin via context menu
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Unpin')
|
||||
|
||||
await expect(fixture.pinIndicator).not.toBeVisible()
|
||||
expect(await nodeRef.isPinned()).toBe(false)
|
||||
})
|
||||
|
||||
test('should bypass node and remove bypass via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeTitle = 'Load Checkpoint'
|
||||
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
})
|
||||
|
||||
test('should minimize and expand node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(fixture.body).toBeVisible()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Minimize Node')
|
||||
await expect(fixture.body).not.toBeVisible()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Expand Node')
|
||||
await expect(fixture.body).toBeVisible()
|
||||
})
|
||||
|
||||
test('should convert node to subgraph via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Image Node Actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('should copy image to clipboard via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
await clickExactMenuItem(comfyPage, 'Copy Image')
|
||||
|
||||
// Verify the clipboard contains an image
|
||||
const hasImage = await comfyPage.page.evaluate(async () => {
|
||||
const items = await navigator.clipboard.read()
|
||||
return items.some((item) =>
|
||||
item.types.some((t) => t.startsWith('image/'))
|
||||
)
|
||||
})
|
||||
expect(hasImage).toBe(true)
|
||||
})
|
||||
|
||||
test('should paste image to LoadImage node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Capture the original image src from the node's preview
|
||||
const imagePreview = comfyPage.vueNodes
|
||||
.getNodeByTitle('Load Image')
|
||||
.getByTestId(TestIds.node.mainImage)
|
||||
const originalSrc = await imagePreview.getAttribute('src')
|
||||
|
||||
// Write a test image into the browser clipboard
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const resp = await fetch('/api/view?filename=example.png&type=input')
|
||||
const blob = await resp.blob()
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob })
|
||||
])
|
||||
})
|
||||
|
||||
// Right-click and select Paste Image
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
await clickExactMenuItem(comfyPage, 'Paste Image')
|
||||
|
||||
// Verify the image preview src changed
|
||||
await expect(imagePreview).not.toHaveAttribute('src', originalSrc!)
|
||||
})
|
||||
|
||||
test('should open image in new tab via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
|
||||
const popupPromise = comfyPage.page.waitForEvent('popup')
|
||||
await clickExactMenuItem(comfyPage, 'Open Image')
|
||||
const popup = await popupPromise
|
||||
|
||||
expect(popup.url()).toContain('/api/view')
|
||||
expect(popup.url()).toContain('filename=')
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
test('should download image via Save Image context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await clickExactMenuItem(comfyPage, 'Save Image')
|
||||
const download = await downloadPromise
|
||||
|
||||
expect(download.suggestedFilename()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Actions', () => {
|
||||
test('should convert to subgraph and unpack back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Convert KSampler to subgraph
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Unpack the subgraph
|
||||
await openContextMenu(comfyPage, 'New Subgraph')
|
||||
await clickExactMenuItem(comfyPage, 'Unpack Subgraph')
|
||||
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should open properties panel via Edit Subgraph Widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Convert to subgraph first
|
||||
await openContextMenu(comfyPage, 'Empty Latent Image')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Right-click subgraph and edit widgets
|
||||
await openContextMenu(comfyPage, 'New Subgraph')
|
||||
await clickExactMenuItem(comfyPage, 'Edit Subgraph Widgets')
|
||||
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should add subgraph to library and find in node library', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Convert to subgraph first
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Add to library
|
||||
await openContextMenu(comfyPage, 'New Subgraph')
|
||||
await clickExactMenuItem(comfyPage, 'Add Subgraph to Library')
|
||||
|
||||
// Fill the blueprint name
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('TestBlueprint')
|
||||
|
||||
// Open node library sidebar and search for the blueprint
|
||||
await comfyPage.menu.nodeLibraryTab.tabButton.click()
|
||||
const searchBox = comfyPage.page.getByRole('combobox', {
|
||||
name: 'Search'
|
||||
})
|
||||
await searchBox.waitFor({ state: 'visible' })
|
||||
await searchBox.fill('TestBlueprint')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.getByText('TestBlueprint')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-Node Actions', () => {
|
||||
const nodeTitles = ['Load Checkpoint', 'KSampler']
|
||||
|
||||
test('should batch rename selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Rename')
|
||||
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('MyNode')
|
||||
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 1')).toBeVisible()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 2')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should copy and paste selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Copy')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + nodeTitles.length
|
||||
)
|
||||
})
|
||||
|
||||
test('should duplicate selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Duplicate')
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + nodeTitles.length
|
||||
)
|
||||
})
|
||||
|
||||
test('should pin and unpin selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Pin')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await expect(fixture.pinIndicator).toBeVisible()
|
||||
}
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Unpin')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await expect(fixture.pinIndicator).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should bypass and remove bypass on selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
expect(await nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
|
||||
}
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
expect(await nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('should minimize and expand selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fixture1 =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
const fixture2 = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await expect(fixture1.body).toBeVisible()
|
||||
await expect(fixture2.body).toBeVisible()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Minimize Node')
|
||||
|
||||
await expect(fixture1.body).not.toBeVisible()
|
||||
await expect(fixture2.body).not.toBeVisible()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Expand Node')
|
||||
|
||||
await expect(fixture1.body).toBeVisible()
|
||||
await expect(fixture2.body).toBeVisible()
|
||||
})
|
||||
|
||||
test('should frame selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialGroupCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.groups.length
|
||||
)
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Frame Nodes')
|
||||
|
||||
const newGroupCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.groups.length
|
||||
)
|
||||
expect(newGroupCount).toBe(initialGroupCount + 1)
|
||||
})
|
||||
|
||||
test('should convert to group node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
|
||||
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
|
||||
|
||||
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'workflow>TestGroupNode'
|
||||
)
|
||||
expect(groupNodes.length).toBe(1)
|
||||
})
|
||||
|
||||
test('should convert selected nodes to subgraph via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount - nodeTitles.length + 1
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -323,6 +323,174 @@ test.describe('Workflow Persistence', () => {
|
||||
expect(linkCountAfter).toBe(linkCountBefore)
|
||||
})
|
||||
|
||||
test('Closing an inactive tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B: duplicate and save
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameB)
|
||||
|
||||
// Add a Note node in B to mark it as modified
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(nodeCountA + 1)
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
// Switch to A via topbar tab (making B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive B tab via middle-click — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameB).click({
|
||||
button: 'middle'
|
||||
})
|
||||
|
||||
// Click "Save" in the dirty close dialog
|
||||
const saveButton = comfyPage.page.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have the extra Note node we added, not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Closing an inactive unsaved tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B as an unsaved workflow with a Note node
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(1)
|
||||
expect(nodeCountA).not.toBe(nodeCountB)
|
||||
|
||||
// Switch to A via topbar tab (making unsaved B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive unsaved B tab — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar
|
||||
.getWorkflowTab('Unsaved Workflow')
|
||||
.click({ button: 'middle' })
|
||||
|
||||
// Click "Save" in the dirty close dialog (scoped to dialog)
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
|
||||
// Fill in the filename dialog
|
||||
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
|
||||
await saveDialog.waitFor({ state: 'visible' })
|
||||
await saveDialog.fill(nameB)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have 1 node (the Note), not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Splitter panel sizes persist correctly in localStorage', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -363,7 +363,7 @@ Test your feature flags with different combinations:
|
||||
### Example Test
|
||||
|
||||
```typescript
|
||||
// In tests-ui/tests/api.featureFlags.test.ts
|
||||
// Example from a colocated unit test
|
||||
it('should handle preview metadata based on feature flag', () => {
|
||||
// Mock server supports feature
|
||||
api.serverFeatureFlags = { supports_preview_metadata: true }
|
||||
|
||||
@@ -17,7 +17,7 @@ This guide covers patterns and examples for testing Pinia stores in the ComfyUI
|
||||
Basic setup for testing Pinia stores:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -51,7 +51,7 @@ describe('useWorkflowStore', () => {
|
||||
Testing store state changes:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
it('should create a temporary workflow with a unique path', () => {
|
||||
const workflow = store.createTemporary()
|
||||
expect(workflow.path).toBe('workflows/Unsaved Workflow.json')
|
||||
@@ -72,7 +72,7 @@ it('should create a temporary workflow not clashing with persisted workflows', a
|
||||
Testing store actions:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
describe('openWorkflow', () => {
|
||||
it('should load and open a temporary workflow', async () => {
|
||||
// Create a test workflow
|
||||
@@ -115,7 +115,7 @@ describe('openWorkflow', () => {
|
||||
Testing store getters:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/store/modelStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
describe('getters', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -162,7 +162,7 @@ describe('getters', () => {
|
||||
Mocking API and other dependencies:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
// Add mock for api at the top of the file
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -205,7 +205,7 @@ describe('syncWorkflows', () => {
|
||||
Testing store watchers and reactive behavior:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
describe('Subgraphs', () => {
|
||||
@@ -253,7 +253,7 @@ describe('Subgraphs', () => {
|
||||
Testing store integration with other parts of the application:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
describe('renameWorkflow', () => {
|
||||
it('should rename workflow and update bookmarks', async () => {
|
||||
const workflow = store.createTemporary('dir/test.json')
|
||||
|
||||
@@ -18,7 +18,7 @@ This guide covers patterns and examples for unit testing utilities, composables,
|
||||
Testing Vue composables requires handling reactivity correctly:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
|
||||
// Example from a colocated composable unit test
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useServerLogs } from '@/composables/useServerLogs'
|
||||
@@ -59,7 +59,7 @@ describe('useServerLogs', () => {
|
||||
Testing LiteGraph-related functionality:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/litegraph.test.ts
|
||||
// Example from a colocated LiteGraph unit test
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('LGraph', () => {
|
||||
Testing with ComfyUI workflow files:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/comfyWorkflow.test.ts
|
||||
// Example from a colocated workflow unit test
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { defaultGraph } from '@/scripts/defaultGraph'
|
||||
@@ -125,7 +125,7 @@ describe('workflow validation', () => {
|
||||
Mocking the ComfyUI API object:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
|
||||
// Example from a colocated composable unit test
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('Function using debounce', () => {
|
||||
When you need to test real debounce/throttle behavior:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/composables/useWorkflowAutoSave.test.ts
|
||||
// Example from a colocated composable unit test
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('debounced function', () => {
|
||||
@@ -223,7 +223,7 @@ describe('debounced function', () => {
|
||||
Creating mock node definitions for testing:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/apiTypes.test.ts
|
||||
// Example from a colocated schema unit test
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type ComfyNodeDef,
|
||||
|
||||
@@ -230,15 +230,6 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['tests-ui/**/*'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ disallowTypeAnnotations: false }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts'],
|
||||
ignores: ['browser_tests/tests/**/*.spec.ts'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.9",
|
||||
"version": "1.43.12",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -73,6 +73,7 @@
|
||||
"@primevue/themes": "catalog:",
|
||||
"@sentry/vue": "catalog:",
|
||||
"@sparkjsdev/spark": "catalog:",
|
||||
"@tanstack/vue-virtual": "catalog:",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-link": "catalog:",
|
||||
"@tiptap/extension-table": "catalog:",
|
||||
@@ -140,6 +141,7 @@
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@testing-library/user-event": "catalog:",
|
||||
"@testing-library/vue": "catalog:",
|
||||
"@total-typescript/shoehorn": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/jsdom": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver-ai}]");
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
|
||||
|
Before Width: | Height: | Size: 534 B After Width: | Height: | Size: 534 B |
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -102,6 +102,9 @@ catalogs:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: ^3.13.12
|
||||
version: 3.13.12
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.9.1
|
||||
version: 6.9.1
|
||||
@@ -135,6 +138,9 @@ catalogs:
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^2.27.2
|
||||
version: 2.27.2
|
||||
'@total-typescript/shoehorn':
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2
|
||||
'@types/fs-extra':
|
||||
specifier: ^11.0.4
|
||||
version: 11.0.4
|
||||
@@ -455,6 +461,9 @@ importers:
|
||||
'@sparkjsdev/spark':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.10
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: 'catalog:'
|
||||
version: 3.13.12(vue@3.5.13(typescript@5.9.3))
|
||||
'@tiptap/core':
|
||||
specifier: 'catalog:'
|
||||
version: 2.27.2(@tiptap/pm@2.27.2)
|
||||
@@ -651,6 +660,9 @@ importers:
|
||||
'@testing-library/vue':
|
||||
specifier: 'catalog:'
|
||||
version: 8.1.0(@vue/compiler-sfc@3.5.28)(vue@3.5.13(typescript@5.9.3))
|
||||
'@total-typescript/shoehorn':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.2
|
||||
'@types/fs-extra':
|
||||
specifier: 'catalog:'
|
||||
version: 11.0.4
|
||||
@@ -4274,6 +4286,9 @@ packages:
|
||||
'@tmcp/auth':
|
||||
optional: true
|
||||
|
||||
'@total-typescript/shoehorn@0.1.2':
|
||||
resolution: {integrity: sha512-p7nNZbOZIofpDNyP0u1BctFbjxD44Qc+oO5jufgQdFdGIXJLc33QRloJpq7k5T59CTgLWfQSUxsuqLcmeurYRw==}
|
||||
|
||||
'@tweenjs/tween.js@23.1.3':
|
||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||
|
||||
@@ -13308,6 +13323,8 @@ snapshots:
|
||||
esm-env: 1.2.2
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
|
||||
'@total-typescript/shoehorn@0.1.2': {}
|
||||
|
||||
'@tweenjs/tween.js@23.1.3': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
|
||||
@@ -30,6 +30,7 @@ catalog:
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@tanstack/vue-virtual': ^3.13.12
|
||||
'@storybook/addon-docs': ^10.2.10
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
@@ -46,6 +47,7 @@ catalog:
|
||||
'@tiptap/extension-table-row': ^2.27.2
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@tiptap/starter-kit': ^2.27.2
|
||||
'@total-typescript/shoehorn': ^0.1.2
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -43,12 +44,12 @@ describe('downloadUtil', () => {
|
||||
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
|
||||
revokeObjectURLSpy.mockClear().mockImplementation(() => {})
|
||||
// Create a mock anchor element
|
||||
mockLink = {
|
||||
mockLink = fromPartial<HTMLAnchorElement>({
|
||||
href: '',
|
||||
download: '',
|
||||
click: vi.fn(),
|
||||
style: { display: '' }
|
||||
} as unknown as HTMLAnchorElement
|
||||
})
|
||||
|
||||
// Spy on DOM methods
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockLink)
|
||||
@@ -172,12 +173,14 @@ describe('downloadUtil', () => {
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
})
|
||||
)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
@@ -198,11 +201,13 @@ describe('downloadUtil', () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
blob: vi.fn()
|
||||
} as Partial<Response> as Response)
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: false,
|
||||
status: 404,
|
||||
blob: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
@@ -224,12 +229,14 @@ describe('downloadUtil', () => {
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
})
|
||||
)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
@@ -256,12 +263,14 @@ describe('downloadUtil', () => {
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
|
||||
)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
})
|
||||
)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
@@ -282,12 +291,14 @@ describe('downloadUtil', () => {
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
})
|
||||
)
|
||||
|
||||
downloadFile(testUrl, 'my-fallback.png')
|
||||
|
||||
@@ -328,11 +339,13 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
})
|
||||
)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
@@ -346,11 +359,13 @@ describe('downloadUtil', () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
})
|
||||
)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
@@ -364,11 +379,10 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as unknown as Response)
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({ ok: false, status: 404 })
|
||||
)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
@@ -381,11 +395,13 @@ describe('downloadUtil', () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
})
|
||||
)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MaybeRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import InputGroupAccordion from '@/components/builder/InputGroupAccordion.vue'
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
@@ -28,12 +29,14 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useInputGroupStore } from '@/stores/inputGroupStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const inputGroupStore = useInputGroupStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -207,13 +210,43 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
}}
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
class="overflow-x-clip"
|
||||
>
|
||||
<AppModeWidgetList builder-mode />
|
||||
</DraggableList>
|
||||
<template v-if="isArrangeMode">
|
||||
<DraggableList
|
||||
:key="inputGroupStore.groupedItemKeys.size"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
class="overflow-x-clip"
|
||||
>
|
||||
<AppModeWidgetList builder-mode />
|
||||
</DraggableList>
|
||||
<div v-if="inputGroupStore.inputGroups.length" class="px-2 pb-2">
|
||||
<InputGroupAccordion
|
||||
v-for="(group, idx) in inputGroupStore.inputGroups"
|
||||
:key="group.id"
|
||||
:group
|
||||
builder-mode
|
||||
:position="
|
||||
inputGroupStore.inputGroups.length === 1
|
||||
? 'only'
|
||||
: idx === 0
|
||||
? 'first'
|
||||
: idx === inputGroupStore.inputGroups.length - 1
|
||||
? 'last'
|
||||
: 'middle'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
class="group/cg flex w-full shrink-0 items-center justify-between border-0 border-t border-border-subtle/40 bg-transparent py-4 pr-5 pl-4 text-sm text-base-foreground outline-none"
|
||||
@click="inputGroupStore.createGroup()"
|
||||
>
|
||||
{{ t('linearMode.groups.createGroup') }}
|
||||
<i
|
||||
class="icon-[lucide--plus] size-5 text-muted-foreground group-hover/cg:text-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectInputsMode"
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { inputItemKey } from '@/components/builder/itemKeyHelper'
|
||||
import { autoGroupName } from '@/components/builder/useInputGroups'
|
||||
import type { PopoverMenuItem } from '@/components/ui/Popover.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
@@ -20,6 +23,7 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useInputGroupStore } from '@/stores/inputGroupStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -42,6 +46,7 @@ const { mobile = false, builderMode = false } = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const inputGroupStore = useInputGroupStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
@@ -62,6 +67,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
>()
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
|
||||
if (inputGroupStore.isGrouped(nodeId, widgetName)) return []
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
@@ -153,6 +159,53 @@ async function handleDragDrop(e: DragEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildMenuEntries(action: WidgetEntry['action']): PopoverMenuItem[] {
|
||||
const entries: PopoverMenuItem[] = [
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'icon-[lucide--pencil]',
|
||||
command: () => promptRenameWidget(action.widget, action.node, t)
|
||||
},
|
||||
{
|
||||
label: t('g.remove'),
|
||||
icon: 'icon-[lucide--x]',
|
||||
command: () =>
|
||||
appModeStore.removeSelectedInput(action.widget, action.node)
|
||||
}
|
||||
]
|
||||
|
||||
if (!builderMode) return entries
|
||||
|
||||
const itemKey = inputItemKey(action.node.id, action.widget.name)
|
||||
const groups = inputGroupStore.inputGroups
|
||||
|
||||
if (groups.length > 0) {
|
||||
entries.push({ separator: true })
|
||||
for (const group of groups) {
|
||||
const name = group.name || autoGroupName(group)
|
||||
entries.push({
|
||||
label: `${t('linearMode.groups.addToGroup')}: ${name}`,
|
||||
icon: 'icon-[lucide--group]',
|
||||
command: () => inputGroupStore.addItemToGroup(group.id, itemKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(
|
||||
...(groups.length === 0 ? [{ separator: true } as PopoverMenuItem] : []),
|
||||
{
|
||||
label: t('linearMode.groups.newGroup'),
|
||||
icon: 'icon-[lucide--plus]',
|
||||
command: () => {
|
||||
const id = inputGroupStore.createGroup()
|
||||
inputGroupStore.addItemToGroup(id, itemKey)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
defineExpose({ handleDragDrop })
|
||||
</script>
|
||||
<template>
|
||||
@@ -165,6 +218,9 @@ defineExpose({ handleDragDrop })
|
||||
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:data-item-key="
|
||||
builderMode ? inputItemKey(action.node.id, action.widget.name) : undefined
|
||||
"
|
||||
:aria-label="
|
||||
builderMode
|
||||
? `${action.widget.label ?? action.widget.name} — ${action.node.title}`
|
||||
@@ -193,19 +249,7 @@ defineExpose({ handleDragDrop })
|
||||
<div v-else class="flex-1" />
|
||||
<Popover
|
||||
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
|
||||
:entries="[
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'icon-[lucide--pencil]',
|
||||
command: () => promptRenameWidget(action.widget, action.node, t)
|
||||
},
|
||||
{
|
||||
label: t('g.remove'),
|
||||
icon: 'icon-[lucide--x]',
|
||||
command: () =>
|
||||
appModeStore.removeSelectedInput(action.widget, action.node)
|
||||
}
|
||||
]"
|
||||
:entries="buildMenuEntries(action)"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
|
||||
@@ -33,76 +33,91 @@
|
||||
{{ t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
|
||||
</Button>
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<div class="relative min-w-24">
|
||||
<!--
|
||||
Invisible sizers: both labels rendered with matching button padding
|
||||
so the container's intrinsic width equals the wider label.
|
||||
height:0 + overflow:hidden keeps them invisible without affecting height.
|
||||
-->
|
||||
<div class="max-h-0 overflow-y-hidden" aria-hidden="true">
|
||||
<div class="px-4 py-2 text-sm">{{ t('g.save') }}</div>
|
||||
<div class="px-4 py-2 text-sm">{{ t('builderToolbar.saveAs') }}</div>
|
||||
</div>
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
class="w-full"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:class="disabledSaveClasses"
|
||||
data-testid="builder-save-as-button"
|
||||
>
|
||||
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</ConnectOutputPopover>
|
||||
<ButtonGroup
|
||||
v-else-if="isSaved"
|
||||
data-testid="builder-save-group"
|
||||
class="w-full rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
:disabled="!isModified"
|
||||
class="flex-1"
|
||||
:class="isModified ? activeSaveClasses : disabledSaveClasses"
|
||||
data-testid="builder-save-button"
|
||||
@click="save()"
|
||||
>
|
||||
{{ t('g.save') }}
|
||||
</Button>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
size="lg"
|
||||
:aria-label="t('builderToolbar.saveAs')"
|
||||
data-save-chevron
|
||||
data-testid="builder-save-as-chevron"
|
||||
class="w-6 rounded-l-none border-l border-border-default px-0"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
|
||||
>
|
||||
<DropdownMenuItem as-child @select="saveAs()">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full justify-start font-normal"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
v-else
|
||||
size="lg"
|
||||
:class="cn('w-24', disabledSaveClasses)"
|
||||
class="w-full"
|
||||
:class="activeSaveClasses"
|
||||
data-testid="builder-save-as-button"
|
||||
@click="saveAs()"
|
||||
>
|
||||
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</ConnectOutputPopover>
|
||||
<ButtonGroup
|
||||
v-else-if="isSaved"
|
||||
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
:disabled="!isModified"
|
||||
class="flex-1"
|
||||
:class="isModified ? activeSaveClasses : disabledSaveClasses"
|
||||
data-testid="builder-save-button"
|
||||
@click="save()"
|
||||
>
|
||||
{{ t('g.save') }}
|
||||
</Button>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
size="lg"
|
||||
:aria-label="t('builderToolbar.saveAs')"
|
||||
data-save-chevron
|
||||
data-testid="builder-save-as-chevron"
|
||||
class="w-6 rounded-l-none border-l border-border-default px-0"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
|
||||
>
|
||||
<DropdownMenuItem as-child @select="saveAs()">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full justify-start font-normal"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
v-else
|
||||
size="lg"
|
||||
:class="activeSaveClasses"
|
||||
data-testid="builder-save-as-button"
|
||||
@click="saveAs()"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
@@ -126,8 +141,6 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
|
||||
import { setWorkflowDefaultView } from './builderViewOptions'
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
|
||||
313
src/components/builder/InputGroupAccordion.vue
Normal file
313
src/components/builder/InputGroupAccordion.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, nextTick, provide, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UngroupConfirmDialog from '@/components/builder/UngroupConfirmDialog.vue'
|
||||
import {
|
||||
vGroupDropTarget,
|
||||
vGroupItemDrag,
|
||||
vGroupItemReorder
|
||||
} from '@/components/builder/useGroupDrop'
|
||||
import {
|
||||
autoGroupName,
|
||||
groupedByPair,
|
||||
resolveGroupItems
|
||||
} from '@/components/builder/useInputGroups'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useInputGroupStore } from '@/stores/inputGroupStore'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
group,
|
||||
builderMode = false,
|
||||
position = 'middle'
|
||||
} = defineProps<{
|
||||
group: InputGroup
|
||||
builderMode?: boolean
|
||||
position?: 'first' | 'middle' | 'last' | 'only'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const inputGroupStore = useInputGroupStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const isOpen = ref(builderMode)
|
||||
const isRenaming = ref(false)
|
||||
const renameInputRef = useTemplateRef<HTMLInputElement>('renameInput')
|
||||
|
||||
watch(isRenaming, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
renameInputRef.value?.focus()
|
||||
renameInputRef.value?.select()
|
||||
})
|
||||
}
|
||||
})
|
||||
const showUngroupDialog = ref(false)
|
||||
const renameValue = ref('')
|
||||
const RENAME_SETTLE_MS = 150
|
||||
const renameOpenedAt = ref(0)
|
||||
|
||||
const displayName = computed(() => group.name ?? autoGroupName(group))
|
||||
const resolvedItems = computed(() => resolveGroupItems(group))
|
||||
const rows = computed(() => groupedByPair(resolvedItems.value))
|
||||
|
||||
function startRename() {
|
||||
if (!builderMode) return
|
||||
renameValue.value = displayName.value
|
||||
renameOpenedAt.value = Date.now()
|
||||
isRenaming.value = true
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
// Guard: blur fires immediately on some browsers before the user can type
|
||||
if (Date.now() - renameOpenedAt.value < RENAME_SETTLE_MS) return
|
||||
const trimmed = renameValue.value.trim()
|
||||
inputGroupStore.renameGroup(group.id, trimmed || null)
|
||||
isRenaming.value = false
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
isRenaming.value = false
|
||||
}
|
||||
|
||||
function startRenameDeferred() {
|
||||
setTimeout(startRename, 50)
|
||||
}
|
||||
|
||||
function handleDissolve() {
|
||||
inputGroupStore.deleteGroup(group.id)
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, value: WidgetValue) {
|
||||
if (value === undefined) return
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot
|
||||
v-model:open="isOpen"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col',
|
||||
builderMode &&
|
||||
'rounded-lg border border-dashed border-primary-background/40',
|
||||
!builderMode && 'border-border-subtle/40',
|
||||
!builderMode &&
|
||||
position !== 'first' &&
|
||||
position !== 'only' &&
|
||||
'border-t',
|
||||
!builderMode &&
|
||||
(position === 'last' || position === 'only') &&
|
||||
'border-b'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Header row -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1',
|
||||
builderMode ? 'py-1 pr-1.5 pl-1' : 'px-4 py-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Rename input -->
|
||||
<div v-if="isRenaming" class="flex flex-1 items-center gap-1.5 px-3 py-2">
|
||||
<input
|
||||
ref="renameInput"
|
||||
v-model="renameValue"
|
||||
type="text"
|
||||
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none"
|
||||
@click.stop
|
||||
@keydown.enter.stop="confirmRename"
|
||||
@keydown.escape.stop="cancelRename"
|
||||
@blur="confirmRename"
|
||||
/>
|
||||
</div>
|
||||
<!-- Name + chevron -->
|
||||
<CollapsibleTrigger v-else as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-1.5 border border-transparent bg-transparent px-3 py-2 text-left outline-none"
|
||||
>
|
||||
<span
|
||||
:title="displayName"
|
||||
class="flex-1 truncate text-sm font-bold text-base-foreground"
|
||||
@dblclick.stop="startRename"
|
||||
>
|
||||
{{ displayName }}
|
||||
</span>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
isOpen && 'rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<!-- Builder actions -->
|
||||
<Popover v-if="builderMode" class="-mr-2 shrink-0">
|
||||
<template #button>
|
||||
<Button variant="textonly" size="icon">
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
startRenameDeferred()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--pencil]" />
|
||||
{{ t('g.rename') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
showUngroupDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--ungroup]" />
|
||||
{{ t('linearMode.groups.ungroup') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<UngroupConfirmDialog
|
||||
v-model:open="showUngroupDialog"
|
||||
@confirm="handleDissolve"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<!-- Builder mode: editable list -->
|
||||
<div
|
||||
v-if="builderMode"
|
||||
v-group-drop-target="{ groupId: group.id }"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-10 flex-col gap-3 px-2 pb-2',
|
||||
'[&.group-drag-over]:bg-primary-background/5'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template
|
||||
v-for="row in rows"
|
||||
:key="row.type === 'single' ? row.item.key : row.items[0].key"
|
||||
>
|
||||
<div
|
||||
v-if="row.type === 'single'"
|
||||
v-group-item-drag="{ itemKey: row.item.key, groupId: group.id }"
|
||||
v-group-item-reorder="{
|
||||
itemKey: row.item.key,
|
||||
groupId: group.id
|
||||
}"
|
||||
class="cursor-grab overflow-hidden rounded-lg p-1.5 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="row.item.widget"
|
||||
:node="row.item.node"
|
||||
hidden-widget-actions
|
||||
hidden-favorite-indicator
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-stretch gap-2">
|
||||
<div
|
||||
v-for="item in row.items"
|
||||
:key="item.key"
|
||||
v-group-item-drag="{ itemKey: item.key, groupId: group.id }"
|
||||
v-group-item-reorder="{
|
||||
itemKey: item.key,
|
||||
groupId: group.id
|
||||
}"
|
||||
class="min-w-0 flex-1 cursor-grab overflow-hidden rounded-lg p-0.5 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="item.widget"
|
||||
:node="item.node"
|
||||
hidden-widget-actions
|
||||
hidden-favorite-indicator
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="group.items.length === 0"
|
||||
class="flex items-center justify-center py-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('linearMode.groups.emptyGroup') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App mode: interactive widgets -->
|
||||
<div v-else class="flex flex-col gap-4 px-4 pt-2 pb-4">
|
||||
<template
|
||||
v-for="row in rows"
|
||||
:key="row.type === 'single' ? row.item.key : row.items[0].key"
|
||||
>
|
||||
<div v-if="row.type === 'single'">
|
||||
<WidgetItem
|
||||
:widget="row.item.widget"
|
||||
:node="row.item.node"
|
||||
hidden-widget-actions
|
||||
@update:widget-value="
|
||||
handleWidgetValueUpdate(row.item.widget, $event)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-stretch gap-2">
|
||||
<div
|
||||
v-for="item in row.items"
|
||||
:key="item.key"
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<WidgetItem
|
||||
:widget="item.widget"
|
||||
:node="item.node"
|
||||
hidden-widget-actions
|
||||
class="w-full"
|
||||
@update:widget-value="
|
||||
handleWidgetValueUpdate(item.widget, $event)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
63
src/components/builder/UngroupConfirmDialog.vue
Normal file
63
src/components/builder/UngroupConfirmDialog.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm')
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-1800 bg-black/50" />
|
||||
<DialogContent
|
||||
class="fixed top-1/2 left-1/2 z-1800 w-80 -translate-1/2 rounded-xl border border-border-subtle bg-base-background p-5 shadow-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<DialogTitle class="text-sm font-medium">
|
||||
{{ t('linearMode.groups.confirmUngroup') }}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
class="flex size-6 items-center justify-center rounded-sm border-0 bg-transparent text-muted-foreground outline-none hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 border-t border-border-subtle pt-3 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('linearMode.groups.ungroupDescription') }}
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-end gap-3">
|
||||
<DialogClose as-child>
|
||||
<Button variant="muted-textonly" size="sm">
|
||||
{{ t('g.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant="secondary" size="lg" @click="handleConfirm">
|
||||
{{ t('linearMode.groups.ungroup') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
55
src/components/builder/itemKeyHelper.test.ts
Normal file
55
src/components/builder/itemKeyHelper.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
groupItemKey,
|
||||
inputItemKey,
|
||||
parseGroupItemKey,
|
||||
parseInputItemKey
|
||||
} from './itemKeyHelper'
|
||||
|
||||
describe('inputItemKey', () => {
|
||||
it('builds key from string nodeId', () => {
|
||||
expect(inputItemKey('42', 'steps')).toBe('input:42:steps')
|
||||
})
|
||||
|
||||
it('builds key from numeric nodeId', () => {
|
||||
expect(inputItemKey(7, 'cfg')).toBe('input:7:cfg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupItemKey', () => {
|
||||
it('builds key from groupId', () => {
|
||||
expect(groupItemKey('abc-123')).toBe('group:abc-123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseInputItemKey', () => {
|
||||
it('parses a valid input key', () => {
|
||||
expect(parseInputItemKey('input:42:steps')).toEqual({
|
||||
nodeId: '42',
|
||||
widgetName: 'steps'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles widget names containing colons', () => {
|
||||
expect(parseInputItemKey('input:5:a:b:c')).toEqual({
|
||||
nodeId: '5',
|
||||
widgetName: 'a:b:c'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-input keys', () => {
|
||||
expect(parseInputItemKey('group:abc')).toBeNull()
|
||||
expect(parseInputItemKey('output:1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGroupItemKey', () => {
|
||||
it('parses a valid group key', () => {
|
||||
expect(parseGroupItemKey('group:abc-123')).toBe('abc-123')
|
||||
})
|
||||
|
||||
it('returns null for non-group keys', () => {
|
||||
expect(parseGroupItemKey('input:1:steps')).toBeNull()
|
||||
})
|
||||
})
|
||||
27
src/components/builder/itemKeyHelper.ts
Normal file
27
src/components/builder/itemKeyHelper.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Build an input item key from nodeId and widgetName. */
|
||||
export function inputItemKey(
|
||||
nodeId: string | number,
|
||||
widgetName: string
|
||||
): string {
|
||||
return `input:${nodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
/** Build a group item key from groupId. */
|
||||
export function groupItemKey(groupId: string): string {
|
||||
return `group:${groupId}`
|
||||
}
|
||||
|
||||
/** Parse an input item key into its nodeId and widgetName parts. Returns null if not an input key. */
|
||||
export function parseInputItemKey(
|
||||
key: string
|
||||
): { nodeId: string; widgetName: string } | null {
|
||||
if (!key.startsWith('input:')) return null
|
||||
const parts = key.split(':')
|
||||
return { nodeId: parts[1], widgetName: parts.slice(2).join(':') }
|
||||
}
|
||||
|
||||
/** Parse a group item key into its groupId. Returns null if not a group key. */
|
||||
export function parseGroupItemKey(key: string): string | null {
|
||||
if (!key.startsWith('group:')) return null
|
||||
return key.slice('group:'.length)
|
||||
}
|
||||
63
src/components/builder/useGroupDrop.test.ts
Normal file
63
src/components/builder/useGroupDrop.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getDragItemKey, getEdgeTriZone } from './useGroupDrop'
|
||||
|
||||
function mockElement(top: number, height: number): HTMLElement {
|
||||
return {
|
||||
getBoundingClientRect: () => ({
|
||||
top,
|
||||
height,
|
||||
bottom: top + height,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
describe('getEdgeTriZone', () => {
|
||||
it('returns "before" for top third', () => {
|
||||
expect(getEdgeTriZone(mockElement(100, 90), 110)).toBe('before')
|
||||
})
|
||||
|
||||
it('returns "center" for middle third', () => {
|
||||
expect(getEdgeTriZone(mockElement(100, 90), 145)).toBe('center')
|
||||
})
|
||||
|
||||
it('returns "after" for bottom third', () => {
|
||||
expect(getEdgeTriZone(mockElement(100, 90), 180)).toBe('after')
|
||||
})
|
||||
|
||||
it('returns "before" at exact top boundary', () => {
|
||||
expect(getEdgeTriZone(mockElement(100, 90), 100)).toBe('before')
|
||||
})
|
||||
|
||||
it('returns "after" at exact bottom boundary', () => {
|
||||
expect(getEdgeTriZone(mockElement(100, 90), 190)).toBe('after')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDragItemKey', () => {
|
||||
it('returns itemKey for group-item type', () => {
|
||||
expect(
|
||||
getDragItemKey({ type: 'group-item', itemKey: 'input:1:steps' })
|
||||
).toBe('input:1:steps')
|
||||
})
|
||||
|
||||
it('returns null for non-group-item type', () => {
|
||||
expect(
|
||||
getDragItemKey({ type: 'other', itemKey: 'input:1:steps' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when itemKey is not a string', () => {
|
||||
expect(getDragItemKey({ type: 'group-item', itemKey: 123 })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty data', () => {
|
||||
expect(getDragItemKey({})).toBeNull()
|
||||
})
|
||||
})
|
||||
267
src/components/builder/useGroupDrop.ts
Normal file
267
src/components/builder/useGroupDrop.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
import { parseInputItemKey } from '@/components/builder/itemKeyHelper'
|
||||
import { useInputGroupStore } from '@/stores/inputGroupStore'
|
||||
|
||||
/** Divide an element into three vertical zones for drop detection. */
|
||||
export function getEdgeTriZone(
|
||||
el: HTMLElement,
|
||||
clientY: number
|
||||
): 'before' | 'center' | 'after' {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const third = rect.height / 3
|
||||
if (clientY < rect.top + third) return 'before'
|
||||
if (clientY > rect.top + third * 2) return 'after'
|
||||
return 'center'
|
||||
}
|
||||
|
||||
export function getDragItemKey(
|
||||
data: Record<string | symbol, unknown>
|
||||
): string | null {
|
||||
if (data.type === 'group-item' && typeof data.itemKey === 'string')
|
||||
return data.itemKey
|
||||
return null
|
||||
}
|
||||
|
||||
function clearIndicator(el: HTMLElement) {
|
||||
el.classList.remove('reorder-before', 'reorder-after', 'pair-indicator')
|
||||
}
|
||||
|
||||
function setIndicator(el: HTMLElement, edge: 'before' | 'center' | 'after') {
|
||||
clearIndicator(el)
|
||||
if (edge === 'center') el.classList.add('pair-indicator')
|
||||
else el.classList.add(`reorder-${edge}`)
|
||||
}
|
||||
|
||||
// ── Item reorder + pair drop target ──────────────────────────────────
|
||||
|
||||
interface ItemReorderBinding {
|
||||
itemKey: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
type ItemReorderEl = HTMLElement & {
|
||||
__reorderCleanup?: () => void
|
||||
__reorderValue?: ItemReorderBinding
|
||||
}
|
||||
|
||||
export const vGroupItemReorder: Directive<HTMLElement, ItemReorderBinding> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as ItemReorderEl
|
||||
typedEl.__reorderValue = value
|
||||
const store = useInputGroupStore()
|
||||
|
||||
typedEl.__reorderCleanup = dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) => {
|
||||
const dragKey = getDragItemKey(source.data)
|
||||
return !!dragKey && dragKey !== typedEl.__reorderValue!.itemKey
|
||||
},
|
||||
onDrag: ({ location }) => {
|
||||
setIndicator(el, getEdgeTriZone(el, location.current.input.clientY))
|
||||
},
|
||||
onDragEnter: ({ location }) => {
|
||||
setIndicator(el, getEdgeTriZone(el, location.current.input.clientY))
|
||||
},
|
||||
onDragLeave: () => clearIndicator(el),
|
||||
onDrop: ({ source, location }) => {
|
||||
clearIndicator(el)
|
||||
const dragKey = getDragItemKey(source.data)
|
||||
if (!dragKey) return
|
||||
|
||||
const { groupId, itemKey } = typedEl.__reorderValue!
|
||||
const edge = getEdgeTriZone(el, location.current.input.clientY)
|
||||
const sameGroup = !!store
|
||||
.findGroup(groupId)
|
||||
?.items.some((i) => i.key === dragKey)
|
||||
|
||||
if (!sameGroup) {
|
||||
store.moveItemToGroupAt(groupId, dragKey, itemKey, edge)
|
||||
return
|
||||
}
|
||||
|
||||
if (edge === 'center') {
|
||||
const targetItem = store
|
||||
.findGroup(groupId)
|
||||
?.items.find((i) => i.key === itemKey)
|
||||
if (targetItem?.pairId) {
|
||||
store.replaceInPair(groupId, itemKey, dragKey)
|
||||
} else {
|
||||
store.pairItemsInGroup(groupId, itemKey, dragKey)
|
||||
}
|
||||
} else {
|
||||
store.unpairItem(groupId, dragKey)
|
||||
store.reorderWithinGroup(groupId, dragKey, itemKey, edge)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as ItemReorderEl).__reorderValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as ItemReorderEl).__reorderCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Draggable item ───────────────────────────────────────────────────
|
||||
|
||||
interface ItemDragBinding {
|
||||
itemKey: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
type ItemDragEl = HTMLElement & {
|
||||
__dragCleanup?: () => void
|
||||
__dragValue?: ItemDragBinding
|
||||
}
|
||||
|
||||
export const vGroupItemDrag: Directive<HTMLElement, ItemDragBinding> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as ItemDragEl
|
||||
typedEl.__dragValue = value
|
||||
const store = useInputGroupStore()
|
||||
|
||||
typedEl.__dragCleanup = draggable({
|
||||
element: el,
|
||||
getInitialData: () => {
|
||||
const parsed = parseInputItemKey(typedEl.__dragValue!.itemKey)
|
||||
return {
|
||||
type: 'group-item',
|
||||
itemKey: typedEl.__dragValue!.itemKey,
|
||||
nodeId: parsed?.nodeId ?? '',
|
||||
widgetName: parsed?.widgetName ?? '',
|
||||
sourceGroupId: typedEl.__dragValue!.groupId
|
||||
}
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
if (location.current.dropTargets.length > 0) return
|
||||
const { groupId, itemKey } = typedEl.__dragValue!
|
||||
// Still over own group body → don't ungroup
|
||||
const overGroup = findGroupDropUnderPointer(
|
||||
location.current.input.clientX,
|
||||
location.current.input.clientY
|
||||
)
|
||||
if (overGroup?.groupId === groupId) return
|
||||
store.removeItemFromGroup(groupId, itemKey)
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as ItemDragEl).__dragValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as ItemDragEl).__dragCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group body drop target (for items dragged from outside) ──────────
|
||||
|
||||
interface GroupDropBinding {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
type GroupDropEl = HTMLElement & {
|
||||
__groupDropCleanup?: () => void
|
||||
__groupDropValue?: GroupDropBinding
|
||||
}
|
||||
|
||||
const GROUP_DROP_ATTR = 'data-group-drop-id'
|
||||
|
||||
/** Find the group drop target under the pointer, ignoring the dragged element. */
|
||||
function findGroupDropUnderPointer(
|
||||
x: number,
|
||||
y: number
|
||||
): { el: HTMLElement; groupId: string } | null {
|
||||
for (const el of document.elementsFromPoint(x, y)) {
|
||||
const groupId = (el as HTMLElement).getAttribute?.(GROUP_DROP_ATTR)
|
||||
if (groupId) return { el: el as HTMLElement, groupId }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Document-level mouseup bridge: when a DraggableList drag ends over a group
|
||||
* drop target, add the item to that group. Captures the item key on mousedown
|
||||
* to avoid racing with DraggableList's cleanup (which removes .is-draggable).
|
||||
*/
|
||||
let pendingDragKey: string | null = null
|
||||
let bridgeRefCount = 0
|
||||
let removeBridge: (() => void) | null = null
|
||||
|
||||
function setupListToGroupBridge() {
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
const target = (e.target as HTMLElement)?.closest?.('.draggable-item')
|
||||
pendingDragKey = (target as HTMLElement)?.dataset?.itemKey ?? null
|
||||
}
|
||||
function onMouseUp(e: MouseEvent) {
|
||||
const itemKey = pendingDragKey
|
||||
pendingDragKey = null
|
||||
if (!itemKey) return
|
||||
|
||||
const target = findGroupDropUnderPointer(e.clientX, e.clientY)
|
||||
if (!target) return
|
||||
|
||||
const store = useInputGroupStore()
|
||||
const group = store.findGroup(target.groupId)
|
||||
if (group?.items.some((i) => i.key === itemKey)) return
|
||||
|
||||
store.addItemToGroup(target.groupId, itemKey)
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
removeBridge = () => {
|
||||
document.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
}
|
||||
|
||||
export const vGroupDropTarget: Directive<HTMLElement, GroupDropBinding> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as GroupDropEl
|
||||
typedEl.__groupDropValue = value
|
||||
const store = useInputGroupStore()
|
||||
|
||||
el.setAttribute(GROUP_DROP_ATTR, value.groupId)
|
||||
|
||||
bridgeRefCount++
|
||||
if (bridgeRefCount === 1) setupListToGroupBridge()
|
||||
|
||||
// Pragmatic DnD drop target (for items dragged within/between groups)
|
||||
typedEl.__groupDropCleanup = dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) => {
|
||||
const itemKey = getDragItemKey(source.data)
|
||||
if (!itemKey) return false
|
||||
const group = store.findGroup(typedEl.__groupDropValue!.groupId)
|
||||
return !group?.items.some((i) => i.key === itemKey)
|
||||
},
|
||||
onDragEnter: () => el.classList.add('group-drag-over'),
|
||||
onDragLeave: () => el.classList.remove('group-drag-over'),
|
||||
onDrop: ({ source, location }) => {
|
||||
el.classList.remove('group-drag-over')
|
||||
if (location.current.dropTargets[0]?.element !== el) return
|
||||
const itemKey = getDragItemKey(source.data)
|
||||
if (!itemKey) return
|
||||
store.addItemToGroup(typedEl.__groupDropValue!.groupId, itemKey)
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as GroupDropEl).__groupDropValue = value
|
||||
el.setAttribute(GROUP_DROP_ATTR, value.groupId)
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as GroupDropEl).__groupDropCleanup?.()
|
||||
el.removeAttribute(GROUP_DROP_ATTR)
|
||||
bridgeRefCount--
|
||||
if (bridgeRefCount === 0) {
|
||||
removeBridge?.()
|
||||
removeBridge = null
|
||||
}
|
||||
}
|
||||
}
|
||||
201
src/components/builder/useInputGroups.test.ts
Normal file
201
src/components/builder/useInputGroups.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
const mockResolveNodeWidget =
|
||||
vi.fn<(...args: unknown[]) => [LGraphNode, IBaseWidget] | [LGraphNode] | []>()
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveNodeWidget: (...args: unknown[]) => mockResolveNodeWidget(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import {
|
||||
autoGroupName,
|
||||
groupedByPair,
|
||||
resolveGroupItems
|
||||
} from './useInputGroups'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function makeNode(id: string): LGraphNode {
|
||||
return { id } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeWidget(name: string, label?: string): IBaseWidget {
|
||||
return { name, label } as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
function makeGroup(items: { key: string; pairId?: string }[]): InputGroup {
|
||||
return { id: 'g1', name: null, items }
|
||||
}
|
||||
|
||||
function makeResolvedItem(key: string, opts: { pairId?: string } = {}) {
|
||||
return {
|
||||
key,
|
||||
pairId: opts.pairId,
|
||||
node: makeNode('1'),
|
||||
widget: makeWidget('w'),
|
||||
nodeId: '1',
|
||||
widgetName: 'w'
|
||||
}
|
||||
}
|
||||
|
||||
describe('groupedByPair', () => {
|
||||
it('returns empty for empty input', () => {
|
||||
expect(groupedByPair([])).toEqual([])
|
||||
})
|
||||
|
||||
it('treats all items without pairId as singles', () => {
|
||||
const items = [makeResolvedItem('a'), makeResolvedItem('b')]
|
||||
const rows = groupedByPair(items)
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toMatchObject({ type: 'single' })
|
||||
expect(rows[1]).toMatchObject({ type: 'single' })
|
||||
})
|
||||
|
||||
it('pairs two items with matching pairId', () => {
|
||||
const items = [
|
||||
makeResolvedItem('a', { pairId: 'p1' }),
|
||||
makeResolvedItem('b', { pairId: 'p1' })
|
||||
]
|
||||
const rows = groupedByPair(items)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].type).toBe('pair')
|
||||
if (rows[0].type === 'pair') {
|
||||
expect(rows[0].items[0].key).toBe('a')
|
||||
expect(rows[0].items[1].key).toBe('b')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders orphaned pairId (no partner) as single', () => {
|
||||
const items = [makeResolvedItem('a', { pairId: 'lonely' })]
|
||||
const rows = groupedByPair(items)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ type: 'single' })
|
||||
})
|
||||
|
||||
it('handles mixed singles and pairs', () => {
|
||||
const items = [
|
||||
makeResolvedItem('a'),
|
||||
makeResolvedItem('b', { pairId: 'p1' }),
|
||||
makeResolvedItem('c', { pairId: 'p1' }),
|
||||
makeResolvedItem('d')
|
||||
]
|
||||
const rows = groupedByPair(items)
|
||||
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[0]).toMatchObject({ type: 'single' })
|
||||
expect(rows[1]).toMatchObject({ type: 'pair' })
|
||||
expect(rows[2]).toMatchObject({ type: 'single' })
|
||||
})
|
||||
|
||||
it('pairs first two of three items with same pairId, third becomes single', () => {
|
||||
const items = [
|
||||
makeResolvedItem('a', { pairId: 'p1' }),
|
||||
makeResolvedItem('b', { pairId: 'p1' }),
|
||||
makeResolvedItem('c', { pairId: 'p1' })
|
||||
]
|
||||
const rows = groupedByPair(items)
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].type).toBe('pair')
|
||||
expect(rows[1]).toMatchObject({ type: 'single' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoGroupName', () => {
|
||||
it('joins widget labels with comma', () => {
|
||||
mockResolveNodeWidget
|
||||
.mockReturnValueOnce([makeNode('1'), makeWidget('w1', 'Width')])
|
||||
.mockReturnValueOnce([makeNode('2'), makeWidget('w2', 'Height')])
|
||||
|
||||
const group = makeGroup([{ key: 'input:1:w1' }, { key: 'input:2:w2' }])
|
||||
|
||||
expect(autoGroupName(group)).toBe('Width, Height')
|
||||
})
|
||||
|
||||
it('falls back to widget name when label is absent', () => {
|
||||
mockResolveNodeWidget.mockReturnValueOnce([
|
||||
makeNode('1'),
|
||||
makeWidget('steps')
|
||||
])
|
||||
|
||||
const group = makeGroup([{ key: 'input:1:steps' }])
|
||||
expect(autoGroupName(group)).toBe('steps')
|
||||
})
|
||||
|
||||
it('returns untitled key when no widgets resolve', () => {
|
||||
mockResolveNodeWidget.mockReturnValue([])
|
||||
|
||||
const group = makeGroup([{ key: 'input:1:w' }])
|
||||
expect(autoGroupName(group)).toBe('linearMode.groups.untitled')
|
||||
})
|
||||
|
||||
it('skips non-input keys', () => {
|
||||
mockResolveNodeWidget.mockReturnValueOnce([
|
||||
makeNode('1'),
|
||||
makeWidget('w', 'OK')
|
||||
])
|
||||
|
||||
const group = makeGroup([{ key: 'output:1:w' }, { key: 'input:1:w' }])
|
||||
|
||||
expect(autoGroupName(group)).toBe('OK')
|
||||
expect(mockResolveNodeWidget).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveGroupItems', () => {
|
||||
it('filters out items where resolveNodeWidget returns empty', () => {
|
||||
mockResolveNodeWidget
|
||||
.mockReturnValueOnce([makeNode('1'), makeWidget('w1')])
|
||||
.mockReturnValueOnce([])
|
||||
|
||||
const group = makeGroup([{ key: 'input:1:w1' }, { key: 'input:2:missing' }])
|
||||
const resolved = resolveGroupItems(group)
|
||||
|
||||
expect(resolved).toHaveLength(1)
|
||||
expect(resolved[0].widgetName).toBe('w1')
|
||||
})
|
||||
|
||||
it('handles widget names containing colons', () => {
|
||||
mockResolveNodeWidget.mockReturnValueOnce([
|
||||
makeNode('5'),
|
||||
makeWidget('a:b:c')
|
||||
])
|
||||
|
||||
const group = makeGroup([{ key: 'input:5:a:b:c' }])
|
||||
const resolved = resolveGroupItems(group)
|
||||
|
||||
expect(resolved).toHaveLength(1)
|
||||
expect(resolved[0].nodeId).toBe('5')
|
||||
expect(resolved[0].widgetName).toBe('a:b:c')
|
||||
})
|
||||
|
||||
it('skips non-input keys', () => {
|
||||
const group = makeGroup([{ key: 'other:1:w' }])
|
||||
const resolved = resolveGroupItems(group)
|
||||
|
||||
expect(resolved).toHaveLength(0)
|
||||
expect(mockResolveNodeWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves pairId on resolved items', () => {
|
||||
mockResolveNodeWidget.mockReturnValueOnce([makeNode('1'), makeWidget('w')])
|
||||
|
||||
const group = makeGroup([{ key: 'input:1:w', pairId: 'p1' }])
|
||||
const resolved = resolveGroupItems(group)
|
||||
|
||||
expect(resolved[0].pairId).toBe('p1')
|
||||
})
|
||||
})
|
||||
86
src/components/builder/useInputGroups.ts
Normal file
86
src/components/builder/useInputGroups.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { parseInputItemKey } from '@/components/builder/itemKeyHelper'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
|
||||
export interface ResolvedGroupItem {
|
||||
key: string
|
||||
pairId?: string
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
/** Row of items to render — single or side-by-side pair. */
|
||||
export type GroupRow =
|
||||
| { type: 'single'; item: ResolvedGroupItem }
|
||||
| { type: 'pair'; items: [ResolvedGroupItem, ResolvedGroupItem] }
|
||||
|
||||
/** Derive a group name from the labels of its contained widgets. */
|
||||
export function autoGroupName(group: InputGroup): string {
|
||||
const labels: string[] = []
|
||||
for (const item of group.items) {
|
||||
const parsed = parseInputItemKey(item.key)
|
||||
if (!parsed) continue
|
||||
const [, widget] = resolveNodeWidget(parsed.nodeId, parsed.widgetName)
|
||||
if (widget) labels.push(widget.label || widget.name)
|
||||
}
|
||||
return labels.join(', ') || t('linearMode.groups.untitled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve item keys to widget/node data.
|
||||
* Items whose node or widget cannot be resolved are silently omitted.
|
||||
*/
|
||||
export function resolveGroupItems(group: InputGroup): ResolvedGroupItem[] {
|
||||
const resolved: ResolvedGroupItem[] = []
|
||||
for (const item of group.items) {
|
||||
const parsed = parseInputItemKey(item.key)
|
||||
if (!parsed) continue
|
||||
const { nodeId, widgetName } = parsed
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (node && widget) {
|
||||
resolved.push({
|
||||
key: item.key,
|
||||
pairId: item.pairId,
|
||||
node,
|
||||
widget,
|
||||
nodeId,
|
||||
widgetName
|
||||
})
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
/** Group resolved items into rows, pairing items with matching pairId. */
|
||||
export function groupedByPair(items: ResolvedGroupItem[]): GroupRow[] {
|
||||
const rows: GroupRow[] = []
|
||||
const paired = new Set<string>()
|
||||
|
||||
for (const item of items) {
|
||||
if (paired.has(item.key)) continue
|
||||
|
||||
if (item.pairId) {
|
||||
const partner = items.find(
|
||||
(other) =>
|
||||
other.key !== item.key &&
|
||||
other.pairId === item.pairId &&
|
||||
!paired.has(other.key)
|
||||
)
|
||||
if (partner) {
|
||||
paired.add(item.key)
|
||||
paired.add(partner.key)
|
||||
rows.push({ type: 'pair', items: [item, partner] })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({ type: 'single', item })
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -51,7 +51,10 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<div
|
||||
class="flex min-w-40 flex-col gap-2 p-2"
|
||||
data-testid="more-menu-content"
|
||||
>
|
||||
<slot :close="hide" />
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
@@ -37,13 +37,13 @@
|
||||
</TreeRoot>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuPortal v-if="showContextMenu">
|
||||
<ContextMenuPortal v-if="showContextMenu && contextMenuNode?.data">
|
||||
<ContextMenuContent
|
||||
class="z-9999 min-w-32 overflow-hidden rounded-md border border-border-default bg-comfy-menu-bg p-1 shadow-md"
|
||||
>
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
|
||||
@select="handleAddToFavorites"
|
||||
@select="handleToggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
@@ -59,6 +59,14 @@
|
||||
: $t('sideToolbar.nodeLibraryTab.sections.favoriteNode')
|
||||
}}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="isCurrentNodeUserBlueprint"
|
||||
class="text-destructive flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
|
||||
@select="handleDeleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
{{ $t('g.delete') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</ContextMenuRoot>
|
||||
@@ -79,6 +87,7 @@ import { computed, provide, ref } from 'vue'
|
||||
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
@@ -98,7 +107,6 @@ const emit = defineEmits<{
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
|
||||
event: MouseEvent
|
||||
]
|
||||
addToFavorites: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
|
||||
@@ -107,6 +115,7 @@ const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
|
||||
provide(InjectKeyContextMenuNode, contextMenuNode)
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
|
||||
const isCurrentNodeBookmarked = computed(() => {
|
||||
const node = contextMenuNode.value
|
||||
@@ -114,9 +123,21 @@ const isCurrentNodeBookmarked = computed(() => {
|
||||
return nodeBookmarkStore.isBookmarked(node.data)
|
||||
})
|
||||
|
||||
function handleAddToFavorites() {
|
||||
if (contextMenuNode.value) {
|
||||
emit('addToFavorites', contextMenuNode.value)
|
||||
const isCurrentNodeUserBlueprint = computed(() =>
|
||||
subgraphStore.isUserBlueprint(contextMenuNode.value?.data?.name)
|
||||
)
|
||||
|
||||
function handleToggleBookmark() {
|
||||
const node = contextMenuNode.value
|
||||
if (node?.data) {
|
||||
nodeBookmarkStore.toggleBookmark(node.data)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteBlueprint() {
|
||||
const name = contextMenuNode.value?.data?.name
|
||||
if (name) {
|
||||
void subgraphStore.deleteBlueprint(name)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@ import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
messages: { en: { g: { delete: 'Delete' } } }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -29,6 +29,17 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockDeleteBlueprint = vi.fn()
|
||||
const mockIsUserBlueprint = vi.fn().mockReturnValue(false)
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: () => ({
|
||||
isUserBlueprint: mockIsUserBlueprint,
|
||||
deleteBlueprint: mockDeleteBlueprint,
|
||||
typePrefix: 'SubgraphBlueprint.'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
@@ -175,8 +186,12 @@ describe('TreeExplorerV2Node', () => {
|
||||
expect(contextMenuNode.value).toEqual(nodeItem.value)
|
||||
})
|
||||
|
||||
it('does not set contextMenuNode for folder items', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
it('clears contextMenuNode when right-clicking a folder', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>({
|
||||
key: 'stale',
|
||||
type: 'node',
|
||||
label: 'Stale'
|
||||
} as RenderedTreeExplorerNode)
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
{ item: createMockItem('folder') },
|
||||
@@ -194,6 +209,59 @@ describe('TreeExplorerV2Node', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('blueprint actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows delete button for user blueprints', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'SubgraphBlueprint.test' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides delete button for non-blueprint nodes', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(false)
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'KSampler' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('always shows bookmark button', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'SubgraphBlueprint.test' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="icon.bookmark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('calls deleteBlueprint when delete button is clicked', async () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const nodeName = 'SubgraphBlueprint.test'
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: nodeName }
|
||||
})
|
||||
})
|
||||
|
||||
await wrapper.find('[aria-label="Delete"]').trigger('click')
|
||||
|
||||
expect(mockDeleteBlueprint).toHaveBeenCalledWith(nodeName)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders node icon for node type', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
|
||||
@@ -25,25 +25,30 @@
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'hover:text-foreground flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
|
||||
'opacity-0 group-hover/tree-node:opacity-100'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
v-if="isUserBlueprint"
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-destructive')"
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] text-xs" />
|
||||
</button>
|
||||
<button
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder -->
|
||||
@@ -53,6 +58,7 @@
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@contextmenu="clearContextMenuNode"
|
||||
>
|
||||
<i
|
||||
v-if="item.hasChildren"
|
||||
@@ -96,6 +102,7 @@ import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -107,6 +114,9 @@ defineOptions({
|
||||
const ROW_CLASS =
|
||||
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
|
||||
|
||||
const ACTION_BTN_CLASS =
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
}>()
|
||||
@@ -120,6 +130,7 @@ const emit = defineEmits<{
|
||||
|
||||
const contextMenuNode = inject(InjectKeyContextMenuNode)
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
|
||||
const nodeDef = computed(() => item.value.data)
|
||||
|
||||
@@ -128,12 +139,22 @@ const isBookmarked = computed(() => {
|
||||
return nodeBookmarkStore.isBookmarked(nodeDef.value)
|
||||
})
|
||||
|
||||
const isUserBlueprint = computed(() =>
|
||||
subgraphStore.isUserBlueprint(nodeDef.value?.name)
|
||||
)
|
||||
|
||||
function toggleBookmark() {
|
||||
if (nodeDef.value) {
|
||||
nodeBookmarkStore.toggleBookmark(nodeDef.value)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteBlueprint() {
|
||||
if (nodeDef.value) {
|
||||
void subgraphStore.deleteBlueprint(nodeDef.value.name)
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
showPreview,
|
||||
@@ -166,6 +187,12 @@ function handleContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearContextMenuNode() {
|
||||
if (contextMenuNode) {
|
||||
contextMenuNode.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter(e: MouseEvent) {
|
||||
if (item.value.type !== 'node') return
|
||||
baseHandleMouseEnter(e)
|
||||
|
||||
@@ -192,3 +192,15 @@ export function curvesToLUT(
|
||||
|
||||
return lut
|
||||
}
|
||||
|
||||
export function curveDataToFloatLUT(
|
||||
curve: CurveData,
|
||||
size: number = 256
|
||||
): Float32Array {
|
||||
const lut = new Float32Array(size)
|
||||
const interpolate = createInterpolator(curve.points, curve.interpolation)
|
||||
for (let i = 0; i < size; i++) {
|
||||
lut[i] = interpolate(i / (size - 1))
|
||||
}
|
||||
return lut
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -9,7 +11,6 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
type TestWidget = BaseDOMWidget<object | string>
|
||||
|
||||
@@ -28,7 +29,7 @@ function createNode(
|
||||
}
|
||||
|
||||
function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
|
||||
return {
|
||||
return fromPartial<TestWidget>({
|
||||
id,
|
||||
node,
|
||||
name: 'test_widget',
|
||||
@@ -40,16 +41,16 @@ function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
|
||||
computedHeight: 40,
|
||||
margin: 10,
|
||||
isVisible: () => true
|
||||
} as unknown as TestWidget
|
||||
})
|
||||
}
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
return {
|
||||
return fromPartial<LGraphCanvas>({
|
||||
graph,
|
||||
low_quality: false,
|
||||
read_only: false,
|
||||
isNodeVisible: vi.fn(() => true)
|
||||
} as unknown as LGraphCanvas
|
||||
})
|
||||
}
|
||||
|
||||
function drawFrame(canvas: LGraphCanvas) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<EditableText
|
||||
:is-editing="showInput"
|
||||
:model-value="editedTitle"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import type { DomWidgetState } from '@/stores/domWidgetStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import DomWidget from './DomWidget.vue'
|
||||
|
||||
const mockUpdatePosition = vi.fn()
|
||||
@@ -63,7 +63,7 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
}
|
||||
})
|
||||
|
||||
const widget = {
|
||||
const widget = fromPartial<BaseDOMWidget<object | string>>({
|
||||
id: 'dom-widget-id',
|
||||
name: 'test_widget',
|
||||
type: 'custom',
|
||||
@@ -71,7 +71,7 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
options: {},
|
||||
node,
|
||||
computedDisabled: false
|
||||
} as unknown as BaseDOMWidget<object | string>
|
||||
})
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { getDomWidgetZIndex } from './domWidgetZIndex'
|
||||
|
||||
describe('getDomWidgetZIndex', () => {
|
||||
@@ -15,7 +15,7 @@ describe('getDomWidgetZIndex', () => {
|
||||
first.order = 0
|
||||
second.order = 1
|
||||
|
||||
const nodes = (graph as unknown as { _nodes: LGraphNode[] })._nodes
|
||||
const nodes = fromPartial<{ _nodes: LGraphNode[] }>(graph)._nodes
|
||||
nodes.splice(nodes.indexOf(first), 1)
|
||||
nodes.push(first)
|
||||
|
||||
|
||||
@@ -197,4 +197,15 @@ onBeforeUnmount(() => {
|
||||
:deep(.p-panel-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.p-slider) {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:deep(.p-slider-handle) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -4px;
|
||||
margin-left: -7px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<label>
|
||||
{{ t('load3d.viewer.cameraType') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.viewer.cameraType') }}</label>
|
||||
<Select
|
||||
v-model="cameraType"
|
||||
:options="cameras"
|
||||
@@ -13,7 +11,7 @@
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="showFOVButton" class="space-y-4">
|
||||
<div v-if="showFOVButton" class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.fov') }}</label>
|
||||
<Slider
|
||||
v-model="fov"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.lightIntensity') }}</label>
|
||||
|
||||
<Slider
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.upDirection') }}</label>
|
||||
<Select
|
||||
v-model="upDirection"
|
||||
@@ -10,7 +10,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideMaterialMode">
|
||||
<div v-if="!hideMaterialMode" class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<div v-if="!hasBackgroundImage" class="flex flex-col gap-2">
|
||||
<label>
|
||||
{{ $t('load3d.backgroundColor') }}
|
||||
</label>
|
||||
<input v-model="backgroundColor" type="color" class="w-full" />
|
||||
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
|
||||
/>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="min-h-0 flex-1">
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItemEvent"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable vue/one-component-per-file -- test stubs */
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- stubs lack ARIA roles; data attributes for props */
|
||||
/* eslint-disable testing-library/prefer-user-event -- fireEvent needed: fake timers require fireEvent for mouseEnter/mouseLeave */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
@@ -6,21 +5,27 @@ import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import './testUtils/mockTanstackVirtualizer'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import JobAssetsList from './JobAssetsList.vue'
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template:
|
||||
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
|
||||
})
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
jobDetailsPopoverStub: {
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template:
|
||||
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
|
||||
default: hoisted.jobDetailsPopoverStub
|
||||
}))
|
||||
|
||||
const AssetsListItemStub = defineComponent({
|
||||
name: 'AssetsListItem',
|
||||
@@ -65,71 +70,81 @@ vi.mock('vue-i18n', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const createResultItem = (
|
||||
type TestPreviewOutput = {
|
||||
url: string
|
||||
isImage: boolean
|
||||
isVideo: boolean
|
||||
}
|
||||
|
||||
type TestTaskRef = {
|
||||
workflowId?: string
|
||||
previewOutput?: TestPreviewOutput
|
||||
}
|
||||
|
||||
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
|
||||
taskRef?: TestTaskRef
|
||||
}
|
||||
|
||||
type TestJobGroup = Omit<JobGroup, 'items'> & {
|
||||
items: TestJobListItem[]
|
||||
}
|
||||
|
||||
const createPreviewOutput = (
|
||||
filename: string,
|
||||
mediaType: string = 'images'
|
||||
): ResultItemImpl => {
|
||||
const item = new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType
|
||||
})
|
||||
Object.defineProperty(item, 'url', {
|
||||
get: () => `/api/view/${filename}`
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
|
||||
const job: ApiJobListItem = {
|
||||
id: `task-${Math.random().toString(36).slice(2)}`,
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
preview_output: null,
|
||||
outputs_count: preview ? 1 : 0,
|
||||
workflow_id: 'workflow-1',
|
||||
priority: 0
|
||||
): TestPreviewOutput => {
|
||||
const url = `/api/view/${filename}`
|
||||
return {
|
||||
url,
|
||||
isImage: mediaType === 'images',
|
||||
isVideo: mediaType === 'video'
|
||||
}
|
||||
const flatOutputs = preview ? [preview] : []
|
||||
return new TaskItemImpl(job, {}, flatOutputs)
|
||||
}
|
||||
|
||||
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
const createTaskRef = (preview?: TestPreviewOutput): TestTaskRef => ({
|
||||
workflowId: 'workflow-1',
|
||||
...(preview && { previewOutput: preview })
|
||||
})
|
||||
|
||||
const buildJob = (
|
||||
overrides: Partial<TestJobListItem> = {}
|
||||
): TestJobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png')),
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
|
||||
...overrides
|
||||
})
|
||||
|
||||
function renderJobAssetsList(
|
||||
jobs: JobListItem[],
|
||||
callbacks: {
|
||||
onViewItem?: (item: JobListItem) => void
|
||||
} = {}
|
||||
) {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items: jobs
|
||||
}
|
||||
]
|
||||
|
||||
function renderJobAssetsList({
|
||||
jobs = [],
|
||||
displayedJobGroups,
|
||||
attrs,
|
||||
onViewItem
|
||||
}: {
|
||||
jobs?: TestJobListItem[]
|
||||
displayedJobGroups?: TestJobGroup[]
|
||||
attrs?: Record<string, string>
|
||||
onViewItem?: (item: JobListItem) => void
|
||||
} = {}) {
|
||||
const user = userEvent.setup()
|
||||
|
||||
const result = render(JobAssetsList, {
|
||||
props: {
|
||||
displayedJobGroups,
|
||||
...(callbacks.onViewItem && { onViewItem: callbacks.onViewItem })
|
||||
displayedJobGroups: (displayedJobGroups ?? [
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items: jobs
|
||||
}
|
||||
]) as JobGroup[],
|
||||
...(onViewItem && { onViewItem })
|
||||
},
|
||||
attrs,
|
||||
global: {
|
||||
stubs: {
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub,
|
||||
AssetsListItem: AssetsListItemStub
|
||||
}
|
||||
}
|
||||
@@ -168,10 +183,57 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('JobAssetsList', () => {
|
||||
it('renders grouped headers alongside job rows', () => {
|
||||
const displayedJobGroups: TestJobGroup[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob({ id: 'job-1' })]
|
||||
},
|
||||
{
|
||||
key: 'yesterday',
|
||||
label: 'Yesterday',
|
||||
items: [buildJob({ id: 'job-2', title: 'Job 2' })]
|
||||
}
|
||||
]
|
||||
|
||||
const { container } = renderJobAssetsList({ displayedJobGroups })
|
||||
|
||||
expect(screen.getByText('Today')).toBeTruthy()
|
||||
expect(screen.getByText('Yesterday')).toBeTruthy()
|
||||
expect(container.querySelector('[data-job-id="job-1"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-job-id="job-2"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('forwards parent attrs to the scroll container', () => {
|
||||
renderJobAssetsList({
|
||||
attrs: {
|
||||
class: 'min-h-0 flex-1'
|
||||
},
|
||||
displayedJobGroups: [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob({ id: 'job-1' })]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('job-assets-list').className.split(' ')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'min-h-0',
|
||||
'flex-1',
|
||||
'h-full',
|
||||
'overflow-y-auto',
|
||||
'pb-4'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('emits viewItem on preview-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const onViewItem = vi.fn()
|
||||
const { user } = renderJobAssetsList([job], { onViewItem })
|
||||
const { user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
await user.click(screen.getByTestId('preview-trigger'))
|
||||
|
||||
@@ -181,7 +243,7 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on double-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const stubRoot = container.querySelector('.assets-list-item-stub')!
|
||||
await user.dblClick(stubRoot)
|
||||
@@ -192,10 +254,10 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const stubRoot = container.querySelector('.assets-list-item-stub')!
|
||||
expect(stubRoot.getAttribute('data-preview-url')).toBe(
|
||||
@@ -211,10 +273,10 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const icon = container.querySelector('.assets-list-item-stub i')!
|
||||
await user.click(icon)
|
||||
@@ -225,10 +287,10 @@ describe('JobAssetsList', () => {
|
||||
it('does not emit viewItem on double-click for non-completed jobs', async () => {
|
||||
const job = buildJob({
|
||||
state: 'running',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png'))
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container, user } = renderJobAssetsList([job], { onViewItem })
|
||||
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const stubRoot = container.querySelector('.assets-list-item-stub')!
|
||||
await user.dblClick(stubRoot)
|
||||
@@ -242,7 +304,7 @@ describe('JobAssetsList', () => {
|
||||
taskRef: createTaskRef()
|
||||
})
|
||||
const onViewItem = vi.fn()
|
||||
const { container } = renderJobAssetsList([job], { onViewItem })
|
||||
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
@@ -256,7 +318,7 @@ describe('JobAssetsList', () => {
|
||||
it('shows and hides the job details popover with hover delays', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
@@ -286,7 +348,7 @@ describe('JobAssetsList', () => {
|
||||
it('keeps the job details popover open while hovering the popover', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
@@ -319,7 +381,7 @@ describe('JobAssetsList', () => {
|
||||
it('positions the popover to the right of rows near the left viewport edge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
@@ -344,7 +406,7 @@ describe('JobAssetsList', () => {
|
||||
it('positions the popover to the left of rows near the right viewport edge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList([job])
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
@@ -370,7 +432,9 @@ describe('JobAssetsList', () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
const { container } = renderJobAssetsList([firstJob, secondJob])
|
||||
const { container } = renderJobAssetsList({
|
||||
jobs: [firstJob, secondJob]
|
||||
})
|
||||
|
||||
const firstRow = container.querySelector('[data-job-id="job-1"]')!
|
||||
const secondRow = container.querySelector('[data-job-id="job-2"]')!
|
||||
@@ -398,7 +462,9 @@ describe('JobAssetsList', () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
const { container } = renderJobAssetsList([firstJob, secondJob])
|
||||
const { container } = renderJobAssetsList({
|
||||
jobs: [firstJob, secondJob]
|
||||
})
|
||||
|
||||
const firstRow = container.querySelector('[data-job-id="job-1"]')!
|
||||
const secondRow = container.querySelector('[data-job-id="job-2"]')!
|
||||
@@ -429,7 +495,7 @@ describe('JobAssetsList', () => {
|
||||
it('does not show details if the hovered row disappears before the show delay ends', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container, rerender } = renderJobAssetsList([job])
|
||||
const { container, rerender } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
|
||||
@@ -1,79 +1,92 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-3 pb-4">
|
||||
<div
|
||||
v-for="group in displayedJobGroups"
|
||||
:key="group.key"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="text-xs leading-none text-text-secondary">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<div
|
||||
v-for="job in group.items"
|
||||
:key="job.id"
|
||||
:data-job-id="job.id"
|
||||
@mouseenter="onJobEnter(job, $event)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
>
|
||||
<AssetsListItem
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
job.state === 'running' && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:preview-url="getJobPreviewUrl(job)"
|
||||
:is-video-preview="isVideoPreviewJob(job)"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@contextmenu.prevent.stop="$emit('menu', job, $event)"
|
||||
@dblclick.stop="emitViewItem(job)"
|
||||
@preview-click="emitViewItem(job)"
|
||||
@click.stop
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
v-bind="$attrs"
|
||||
data-testid="job-assets-list"
|
||||
class="h-full overflow-y-auto pb-4"
|
||||
@scroll="onListScroll"
|
||||
>
|
||||
<div :style="virtualWrapperStyle">
|
||||
<template v-for="{ row, virtualItem } in virtualRows" :key="row.key">
|
||||
<div
|
||||
v-if="row.type === 'header'"
|
||||
class="box-border px-3 pb-2 text-xs leading-none text-text-secondary"
|
||||
:style="getVirtualRowStyle(virtualItem)"
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emitCancelItem(job)"
|
||||
{{ row.label }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.type === 'job'"
|
||||
class="box-border px-3"
|
||||
:style="getVirtualRowStyle(virtualItem)"
|
||||
>
|
||||
<div
|
||||
:data-job-id="row.job.id"
|
||||
class="h-12"
|
||||
@mouseenter="onJobEnter(row.job, $event)"
|
||||
@mouseleave="onJobLeave(row.job.id)"
|
||||
>
|
||||
<AssetsListItem
|
||||
:class="
|
||||
cn(
|
||||
'size-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
row.job.state === 'running' && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:preview-url="getJobPreviewUrl(row.job)"
|
||||
:is-video-preview="isVideoPreviewJob(row.job)"
|
||||
:preview-alt="row.job.title"
|
||||
:icon-name="row.job.iconName ?? iconForJobState(row.job.state)"
|
||||
:icon-class="getJobIconClass(row.job)"
|
||||
:primary-text="row.job.title"
|
||||
:secondary-text="row.job.meta"
|
||||
:progress-total-percent="row.job.progressTotalPercent"
|
||||
:progress-current-percent="row.job.progressCurrentPercent"
|
||||
@contextmenu.prevent.stop="$emit('menu', row.job, $event)"
|
||||
@dblclick.stop="emitViewItem(row.job)"
|
||||
@preview-click="emitViewItem(row.job)"
|
||||
@click.stop
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emitDeleteItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="$emit('menu', job, $event)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
<template v-if="hoveredJobId === row.job.id" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(row.job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emitCancelItem(row.job)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(row.job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emitDeleteItem(row.job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="row.job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(row.job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="$emit('menu', row.job, $event)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,8 +110,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { VirtualItem } from '@tanstack/vue-virtual'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
@@ -110,6 +126,17 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
import { buildVirtualJobRows } from './buildVirtualJobRows'
|
||||
import type { VirtualJobRow } from './buildVirtualJobRows'
|
||||
|
||||
const HEADER_ROW_HEIGHT = 20
|
||||
const GROUP_ROW_GAP = 16
|
||||
const JOB_ROW_HEIGHT = 48
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -120,9 +147,43 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const activeRowElement = ref<HTMLElement | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
|
||||
const virtualizer = useVirtualizer({
|
||||
get count(): number {
|
||||
return flatRows.value.length
|
||||
},
|
||||
getItemKey(index: number) {
|
||||
return flatRows.value[index]?.key ?? index
|
||||
},
|
||||
estimateSize(index: number) {
|
||||
const row = flatRows.value[index]
|
||||
return row ? getRowHeight(row, index, flatRows.value) : JOB_ROW_HEIGHT
|
||||
},
|
||||
getScrollElement() {
|
||||
return scrollContainer.value
|
||||
},
|
||||
overscan: 12
|
||||
})
|
||||
const virtualRows = computed(() => {
|
||||
const rows = flatRows.value
|
||||
return virtualizer.value
|
||||
.getVirtualItems()
|
||||
.flatMap((virtualItem: VirtualItem) => {
|
||||
const row = rows[virtualItem.index]
|
||||
return row ? [{ row, virtualItem }] : []
|
||||
})
|
||||
})
|
||||
const virtualWrapperStyle = computed<CSSProperties>(() => ({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
...(flatRows.value.length > 0 && {
|
||||
height: `${virtualizer.value.getTotalSize()}px`
|
||||
})
|
||||
}))
|
||||
const {
|
||||
activeDetails,
|
||||
clearHoverTimers,
|
||||
@@ -135,6 +196,37 @@ const {
|
||||
onReset: clearPopoverAnchor
|
||||
})
|
||||
|
||||
function getVirtualRowStyle(virtualItem: VirtualItem): CSSProperties {
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
overflowAnchor: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
function getRowHeight(
|
||||
row: VirtualJobRow,
|
||||
index: number,
|
||||
rows: VirtualJobRow[]
|
||||
): number {
|
||||
if (row.type === 'header') {
|
||||
return HEADER_ROW_HEIGHT
|
||||
}
|
||||
|
||||
return (
|
||||
JOB_ROW_HEIGHT + (rows[index + 1]?.type === 'header' ? GROUP_ROW_GAP : 0)
|
||||
)
|
||||
}
|
||||
|
||||
function onListScroll() {
|
||||
hoveredJobId.value = null
|
||||
resetActiveDetails()
|
||||
}
|
||||
|
||||
function clearPopoverAnchor() {
|
||||
activeRowElement.value = null
|
||||
popoverPosition.value = null
|
||||
|
||||
82
src/components/queue/job/buildVirtualJobRows.test.ts
Normal file
82
src/components/queue/job/buildVirtualJobRows.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
import { buildVirtualJobRows } from './buildVirtualJobRows'
|
||||
|
||||
function buildJob(id: string): JobListItem {
|
||||
return {
|
||||
id,
|
||||
title: `Job ${id}`,
|
||||
meta: 'meta',
|
||||
state: 'completed'
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildVirtualJobRows', () => {
|
||||
it('flattens grouped jobs into headers and rows in display order', () => {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob('job-1'), buildJob('job-2')]
|
||||
},
|
||||
{
|
||||
key: 'yesterday',
|
||||
label: 'Yesterday',
|
||||
items: [buildJob('job-3')]
|
||||
}
|
||||
]
|
||||
|
||||
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
|
||||
{
|
||||
key: 'header-today',
|
||||
type: 'header',
|
||||
label: 'Today'
|
||||
},
|
||||
{
|
||||
key: 'job-job-1',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[0].items[0]
|
||||
},
|
||||
{
|
||||
key: 'job-job-2',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[0].items[1]
|
||||
},
|
||||
{
|
||||
key: 'header-yesterday',
|
||||
type: 'header',
|
||||
label: 'Yesterday'
|
||||
},
|
||||
{
|
||||
key: 'job-job-3',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[1].items[0]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a single group flattened without extra row metadata', () => {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
items: [buildJob('job-1')]
|
||||
}
|
||||
]
|
||||
|
||||
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
|
||||
{
|
||||
key: 'header-today',
|
||||
type: 'header',
|
||||
label: 'Today'
|
||||
},
|
||||
{
|
||||
key: 'job-job-1',
|
||||
type: 'job',
|
||||
job: displayedJobGroups[0].items[0]
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
37
src/components/queue/job/buildVirtualJobRows.ts
Normal file
37
src/components/queue/job/buildVirtualJobRows.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
export type VirtualJobRow =
|
||||
| {
|
||||
key: string
|
||||
type: 'header'
|
||||
label: string
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
type: 'job'
|
||||
job: JobListItem
|
||||
}
|
||||
|
||||
export function buildVirtualJobRows(
|
||||
displayedJobGroups: JobGroup[]
|
||||
): VirtualJobRow[] {
|
||||
const rows: VirtualJobRow[] = []
|
||||
|
||||
displayedJobGroups.forEach((group) => {
|
||||
rows.push({
|
||||
key: `header-${group.key}`,
|
||||
type: 'header',
|
||||
label: group.label
|
||||
})
|
||||
|
||||
group.items.forEach((job) => {
|
||||
rows.push({
|
||||
key: `job-${job.id}`,
|
||||
type: 'job',
|
||||
job
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/vue-virtual', async () => {
|
||||
const { computed } = await import('vue')
|
||||
|
||||
return {
|
||||
useVirtualizer: (options: {
|
||||
count: number
|
||||
estimateSize: (index: number) => number
|
||||
getItemKey?: (index: number) => number | string
|
||||
}) =>
|
||||
computed(() => {
|
||||
let start = 0
|
||||
const items = Array.from({ length: options.count }, (_, index) => {
|
||||
const size = options.estimateSize(index)
|
||||
const item = {
|
||||
key: options.getItemKey?.(index) ?? index,
|
||||
index,
|
||||
start,
|
||||
size
|
||||
}
|
||||
|
||||
start += size
|
||||
return item
|
||||
})
|
||||
|
||||
return {
|
||||
getVirtualItems: () => items,
|
||||
getTotalSize: () => start
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -303,6 +303,7 @@ function handleTitleCancel() {
|
||||
v-if="isSingleSubgraphNode"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
data-testid="subgraph-editor-toggle"
|
||||
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
|
||||
@click="
|
||||
rightSidePanelStore.openPanel(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<div
|
||||
v-if="singleRuntimeErrorCard"
|
||||
data-testid="runtime-error-panel"
|
||||
aria-live="polite"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
|
||||
>
|
||||
<div
|
||||
@@ -168,7 +169,15 @@
|
||||
v-else-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateModel"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-else-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
@@ -225,6 +234,7 @@ import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
downloadModel,
|
||||
@@ -261,7 +271,8 @@ const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model'
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
@@ -283,6 +294,7 @@ const {
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
@@ -393,7 +405,7 @@ function handleLocateMissingNode(nodeId: string) {
|
||||
focusNode(nodeId, missingNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateModel(nodeId: string) {
|
||||
function handleLocateAssetNode(nodeId: string) {
|
||||
focusNode(nodeId)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -159,7 +160,7 @@ describe('swapNodeGroups computed', () => {
|
||||
|
||||
it('excludes string nodeType entries', async () => {
|
||||
const swap = getSwapNodeGroups([
|
||||
'StringGroupNode' as unknown as MissingNodeType,
|
||||
fromAny<MissingNodeType, unknown>('StringGroupNode'),
|
||||
makeMissingNodeType('OldNode', {
|
||||
nodeId: '1',
|
||||
isReplaceable: true,
|
||||
|
||||
@@ -25,3 +25,4 @@ export type ErrorGroup =
|
||||
| { type: 'missing_node'; title: string; priority: number }
|
||||
| { type: 'swap_nodes'; title: string; priority: number }
|
||||
| { type: 'missing_model'; title: string; priority: number }
|
||||
| { type: 'missing_media'; title: string; priority: number }
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useErrorActions() {
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
return commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
function findOnGitHub(errorMessage: string) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -215,7 +216,7 @@ describe('useErrorGroups', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
'StringGroupNode' as unknown as MissingNodeType
|
||||
fromAny<MissingNodeType, unknown>('StringGroupNode')
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
@@ -29,7 +30,9 @@ import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
@@ -239,6 +242,7 @@ export function useErrorGroups(
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
@@ -635,6 +639,27 @@ export function useErrorGroups(
|
||||
]
|
||||
}
|
||||
|
||||
const missingMediaGroups = computed<MissingMediaGroup[]>(() => {
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
return groupCandidatesByMediaType(candidates)
|
||||
})
|
||||
|
||||
function buildMissingMediaGroups(): ErrorGroup[] {
|
||||
if (!missingMediaGroups.value.length) return []
|
||||
const totalItems = missingMediaGroups.value.reduce(
|
||||
(count, group) => count + group.items.length,
|
||||
0
|
||||
)
|
||||
return [
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
@@ -645,6 +670,7 @@ export function useErrorGroups(
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...buildMissingMediaGroups(),
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
})
|
||||
@@ -663,6 +689,7 @@ export function useErrorGroups(
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...buildMissingMediaGroups(),
|
||||
...executionGroups
|
||||
]
|
||||
})
|
||||
@@ -699,6 +726,7 @@ export function useErrorGroups(
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
|
||||
import { computed, reactive, toValue, watch } from 'vue'
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
@@ -28,66 +28,73 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
|
||||
)
|
||||
})
|
||||
|
||||
let cancelled = false
|
||||
onUnmounted(() => {
|
||||
cancelled = true
|
||||
})
|
||||
watch(
|
||||
() => toValue(cardSource),
|
||||
async (card, _, onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const card = toValue(cardSource)
|
||||
const runtimeErrors = card.errors
|
||||
.map((error, idx) => ({ error, idx }))
|
||||
.filter(({ error }) => error.isRuntimeError)
|
||||
for (const key of Object.keys(enrichedDetails)) {
|
||||
delete enrichedDetails[key as unknown as number]
|
||||
}
|
||||
|
||||
if (runtimeErrors.length === 0) return
|
||||
const runtimeErrors = card.errors
|
||||
.map((error, idx) => ({ error, idx }))
|
||||
.filter(({ error }) => error.isRuntimeError)
|
||||
|
||||
if (!systemStatsStore.systemStats) {
|
||||
if (systemStatsStore.isLoading) {
|
||||
await until(systemStatsStore.isLoading).toBe(false)
|
||||
} else {
|
||||
try {
|
||||
await systemStatsStore.refetchSystemStats()
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch system stats for error report:', e)
|
||||
return
|
||||
if (runtimeErrors.length === 0) return
|
||||
|
||||
if (!systemStatsStore.systemStats) {
|
||||
if (systemStatsStore.isLoading) {
|
||||
await until(() => systemStatsStore.isLoading).toBe(false)
|
||||
} else {
|
||||
try {
|
||||
await systemStatsStore.refetchSystemStats()
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch system stats for error report:', e)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!systemStatsStore.systemStats || cancelled) return
|
||||
if (!systemStatsStore.systemStats || cancelled) return
|
||||
|
||||
const logs = await api
|
||||
.getLogs()
|
||||
.catch(() => 'Failed to retrieve server logs')
|
||||
if (cancelled) return
|
||||
const logs = await api
|
||||
.getLogs()
|
||||
.catch(() => 'Failed to retrieve server logs')
|
||||
if (cancelled) return
|
||||
|
||||
const workflow = (() => {
|
||||
try {
|
||||
return app.rootGraph.serialize()
|
||||
} catch (e) {
|
||||
console.warn('Failed to serialize workflow for error report:', e)
|
||||
return null
|
||||
const workflow = (() => {
|
||||
try {
|
||||
return app.rootGraph.serialize()
|
||||
} catch (e) {
|
||||
console.warn('Failed to serialize workflow for error report:', e)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (!workflow) return
|
||||
|
||||
for (const { error, idx } of runtimeErrors) {
|
||||
try {
|
||||
const report = generateErrorReport({
|
||||
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
|
||||
exceptionMessage: error.message,
|
||||
traceback: error.details,
|
||||
nodeId: card.nodeId,
|
||||
nodeType: card.title,
|
||||
systemStats: systemStatsStore.systemStats,
|
||||
serverLogs: logs,
|
||||
workflow
|
||||
})
|
||||
enrichedDetails[idx] = report
|
||||
} catch (e) {
|
||||
console.warn('Failed to generate error report:', e)
|
||||
}
|
||||
}
|
||||
})()
|
||||
if (!workflow) return
|
||||
|
||||
for (const { error, idx } of runtimeErrors) {
|
||||
try {
|
||||
const report = generateErrorReport({
|
||||
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
|
||||
exceptionMessage: error.message,
|
||||
traceback: error.details,
|
||||
nodeId: card.nodeId,
|
||||
nodeType: card.title,
|
||||
systemStats: systemStatsStore.systemStats,
|
||||
serverLogs: logs,
|
||||
workflow
|
||||
})
|
||||
enrichedDetails[idx] = report
|
||||
} catch (e) {
|
||||
console.warn('Failed to generate error report:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { displayedDetailsMap }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { getSourceNodeId } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
@@ -79,7 +78,6 @@ function isWidgetShownOnParents(
|
||||
): boolean {
|
||||
return parents.some((parent) => {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const sourceNodeId = getSourceNodeId(widget)
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
@@ -88,7 +86,7 @@ function isWidgetShownOnParents(
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: sourceNodeId
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
|
||||
@@ -14,10 +14,7 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
getSourceNodeId,
|
||||
getWidgetName
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
@@ -132,7 +129,9 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(widget)
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
|
||||
? widget.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import type { Slots } from 'vue'
|
||||
@@ -10,7 +11,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
|
||||
@@ -93,13 +93,14 @@ describe('WidgetActions', () => {
|
||||
}
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
return {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [200, 100]
|
||||
} as unknown as LGraphNode
|
||||
size: [200, 100],
|
||||
isSubgraphNode: () => false
|
||||
})
|
||||
}
|
||||
|
||||
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
|
||||
@@ -216,17 +217,18 @@ describe('WidgetActions', () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'CUSTOM'
|
||||
})
|
||||
const parentSubgraphNode = {
|
||||
const parentSubgraphNode = fromAny<SubgraphNode, unknown>({
|
||||
id: 4,
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [300, 150]
|
||||
} as unknown as SubgraphNode
|
||||
const node = {
|
||||
})
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 4,
|
||||
type: 'SubgraphNode',
|
||||
rootGraph: { id: 'graph-test' }
|
||||
} as unknown as LGraphNode
|
||||
rootGraph: { id: 'graph-test' },
|
||||
isSubgraphNode: () => false
|
||||
})
|
||||
const widget = {
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetT
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demoteWidget,
|
||||
getSourceNodeId,
|
||||
isLinkedPromotion,
|
||||
promoteWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -47,6 +47,11 @@ const promotionStore = usePromotionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const isLinked = computed(() => {
|
||||
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
|
||||
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
|
||||
})
|
||||
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
|
||||
const favoriteNode = computed(() =>
|
||||
isShownOnParents && hasParents.value ? parents[0] : node
|
||||
)
|
||||
@@ -76,8 +81,6 @@ function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const disambiguatingSourceNodeId = getSourceNodeId(widget)
|
||||
|
||||
for (const parent of parents) {
|
||||
const source: PromotedWidgetSource = {
|
||||
sourceNodeId:
|
||||
@@ -85,7 +88,7 @@ function handleHideInput() {
|
||||
? widget.sourceNodeId
|
||||
: String(node.id),
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
}
|
||||
promotionStore.demote(parent.rootGraph.id, parent.id, source)
|
||||
parent.computeSize(parent.size)
|
||||
@@ -114,6 +117,7 @@ function handleResetToDefault() {
|
||||
<template>
|
||||
<MoreButton
|
||||
is-vertical
|
||||
data-testid="widget-actions-menu-button"
|
||||
class="bg-transparent text-muted-foreground transition-all hover:bg-secondary-background-hover hover:text-base-foreground active:scale-95"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
@@ -133,7 +137,7 @@ function handleResetToDefault() {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasParents"
|
||||
v-if="canToggleVisibility"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -72,13 +73,13 @@ const i18n = createI18n({
|
||||
})
|
||||
|
||||
function createMockNode(overrides: Partial<LGraphNode> = {}): LGraphNode {
|
||||
return {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
isSubgraphNode: () => false,
|
||||
graph: { rootGraph: { id: 'test-graph-id' } },
|
||||
...overrides
|
||||
} as unknown as LGraphNode
|
||||
})
|
||||
}
|
||||
|
||||
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
@@ -128,7 +129,7 @@ function createMockPromotedWidgetView(
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return new MockPromotedWidgetView() as unknown as IBaseWidget
|
||||
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
|
||||
}
|
||||
|
||||
function mountWidgetItem(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getPromotableWidgets,
|
||||
getSourceNodeId,
|
||||
getWidgetName,
|
||||
isLinkedPromotion,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
pruneDisconnected
|
||||
@@ -88,14 +89,13 @@ const activeWidgets = computed<WidgetItem[]>({
|
||||
promotionStore.setPromotions(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
value.map(([n, w]) => {
|
||||
const sid = getSourceNodeId(w)
|
||||
return {
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
...(sid && { disambiguatingSourceNodeId: sid })
|
||||
}
|
||||
})
|
||||
value.map(([n, w]) => ({
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(w)
|
||||
? w.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
}))
|
||||
)
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
@@ -123,7 +123,9 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(w)
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(w)
|
||||
? w.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -162,6 +164,18 @@ function refreshPromotedWidgetRendering() {
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function isItemLinked([node, widget]: WidgetItem): boolean {
|
||||
return (
|
||||
node.id === -1 ||
|
||||
(!!activeNode.value &&
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(node.id),
|
||||
getWidgetName(widget)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
function toKey(item: WidgetItem) {
|
||||
const sid = getSourceNodeId(item[1])
|
||||
return sid
|
||||
@@ -187,8 +201,14 @@ function showAll() {
|
||||
}
|
||||
}
|
||||
function hideAll() {
|
||||
const node = activeNode.value
|
||||
for (const item of filteredActive.value) {
|
||||
if (String(item[0].id) === '-1') continue
|
||||
if (
|
||||
node &&
|
||||
isLinkedPromotion(node, String(item[0].id), getWidgetName(item[1]))
|
||||
)
|
||||
continue
|
||||
demote(item)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +243,7 @@ onMounted(() => {
|
||||
|
||||
<div
|
||||
v-if="filteredActive.length"
|
||||
data-testid="subgraph-editor-shown-section"
|
||||
class="flex flex-col border-b border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
@@ -244,8 +265,8 @@ onMounted(() => {
|
||||
:key="toKey([node, widget])"
|
||||
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-physical="node.id === -1"
|
||||
:widget-name="widget.label || widget.name"
|
||||
:is-physical="isItemLinked([node, widget])"
|
||||
:is-draggable="!searchQuery"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
@@ -254,6 +275,7 @@ onMounted(() => {
|
||||
|
||||
<div
|
||||
v-if="filteredCandidates.length"
|
||||
data-testid="subgraph-editor-hidden-section"
|
||||
class="flex flex-col border-b border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import type { ClassValue } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
nodeTitle,
|
||||
widgetName,
|
||||
isDraggable = false,
|
||||
isPhysical = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
nodeTitle: string
|
||||
widgetName: string
|
||||
isDraggable?: boolean
|
||||
@@ -14,13 +22,13 @@ defineEmits<{
|
||||
(e: 'toggleVisibility'): void
|
||||
}>()
|
||||
|
||||
function getIcon() {
|
||||
return props.isPhysical
|
||||
const icon = computed(() =>
|
||||
isPhysical
|
||||
? 'icon-[lucide--link]'
|
||||
: props.isDraggable
|
||||
: isDraggable
|
||||
? 'icon-[lucide--eye]'
|
||||
: 'icon-[lucide--eye-off]'
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,8 +37,8 @@ function getIcon() {
|
||||
cn(
|
||||
'flex items-center gap-1 rounded-sm px-2 py-1 break-all',
|
||||
'bg-node-component-surface',
|
||||
props.isDraggable && 'ring-accent-background hover:ring-1',
|
||||
props.class
|
||||
isDraggable && 'ring-accent-background hover:ring-1',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -38,15 +46,18 @@ function getIcon() {
|
||||
<div class="line-clamp-1 text-xs text-text-secondary">
|
||||
{{ nodeTitle }}
|
||||
</div>
|
||||
<div class="line-clamp-1 text-sm/8">{{ widgetName }}</div>
|
||||
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">
|
||||
{{ widgetName }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
data-testid="subgraph-widget-toggle"
|
||||
:disabled="isPhysical"
|
||||
@click.stop="$emit('toggleVisibility')"
|
||||
>
|
||||
<i :class="getIcon()" />
|
||||
<i :class="icon" :data-testid="isPhysical ? 'icon-link' : 'icon-eye'" />
|
||||
</Button>
|
||||
<div
|
||||
v-if="isDraggable"
|
||||
|
||||
@@ -143,11 +143,16 @@
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button size="icon" @click="handleDownloadSelected">
|
||||
<Button
|
||||
size="icon"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -156,12 +161,17 @@
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
variant="secondary"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" @click="handleDownloadSelected">
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user