Compare commits

..

1 Commits

Author SHA1 Message Date
Kelly Yang
7b0d59552d test: add E2E tests for ImageCropV2 drag and free resize (PR 1/4)
- Update workflow asset to include LoadImage source node connected to
  ImageCropV2, enabling image injection in tests
- Add injectSourceImage helper that sets preview images via the Pinia
  nodeOutput store, triggering the composable's reactive image URL update
- Add setCropState / getCropState helpers for controlled test setup and
  assertion
- Add dragFrom / getResizeHandle helpers for pointer interaction
- Level 2 tests (drag): moves box, clamps right, clamps top-left,
  no-op when no image
- Level 3 tests (free resize): all 8 handle directions, MIN_CROP_SIZE
  enforcement, boundary clamping, handle count when ratio unlocked
2026-03-29 16:51:56 -07:00
222 changed files with 2061 additions and 11648 deletions

View File

@@ -95,7 +95,7 @@ jobs:
if npx license-checker-rseidelsohn@4 \
--production \
--summary \
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/ingest-types;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
--clarificationsFile .github/license-clarifications.json \
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
echo ''

View File

@@ -1,4 +1,4 @@
# Description: Unit and component testing with Vitest + coverage reporting
# Description: Unit and component testing with Vitest
name: 'CI: Tests Unit'
on:
@@ -23,12 +23,5 @@ jobs:
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
fail_ci_if_error: false
- name: Run Vitest tests
run: pnpm test:unit

View File

@@ -69,9 +69,6 @@
/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

View File

@@ -1,47 +0,0 @@
<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>

View File

@@ -1,66 +0,0 @@
<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>

View File

@@ -1,77 +0,0 @@
<!-- 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>

View File

@@ -1,62 +0,0 @@
<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>

View File

@@ -1,68 +0,0 @@
<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>

View File

@@ -1,26 +0,0 @@
<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">
&laquo;
</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>

View File

@@ -1,51 +0,0 @@
<!-- 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>

View File

@@ -1,58 +0,0 @@
<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>

View File

@@ -1,92 +0,0 @@
<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">
&ldquo;{{ testimonial.quote }}&rdquo;
</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>

View File

@@ -1,74 +0,0 @@
<!-- 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>

View File

@@ -1,67 +0,0 @@
<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>

View File

@@ -1,34 +0,0 @@
---
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>

View File

@@ -1,34 +0,0 @@
---
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>

View File

@@ -119,7 +119,7 @@ When writing new tests, follow these patterns:
```typescript
// Import the test fixture
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Feature Name', () => {
// Set up test environment if needed
@@ -148,12 +148,6 @@ 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**:

View File

@@ -1,42 +0,0 @@
{
"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
}

View File

@@ -1,30 +0,0 @@
{
"1": {
"class_type": "KSampler",
"inputs": {
"seed": 42,
"steps": 20,
"cfg": 8.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0
},
"_meta": { "title": "KSampler" }
},
"2": {
"class_type": "NonExistentCustomNode_XYZ_12345",
"inputs": {
"input1": "test"
},
"_meta": { "title": "Missing Node" }
},
"3": {
"class_type": "EmptyLatentImage",
"inputs": {
"width": 512,
"height": 512,
"batch_size": 1
},
"_meta": { "title": "Empty Latent Image" }
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

View File

@@ -1,9 +1,36 @@
{
"last_node_id": 12,
"last_link_id": 0,
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 10,
"id": 1,
"type": "ImageCropV2",
"pos": [400, 50],
"size": [200, 300],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [{ "x": 0, "y": 0, "width": 512, "height": 512 }]
},
{
"id": 2,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
@@ -15,7 +42,7 @@
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
"links": [1]
},
{
"name": "MASK",
@@ -26,36 +53,10 @@
"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"]
"widgets_values": ["example.png", "image"]
}
],
"links": [],
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {

View File

@@ -1,45 +1,46 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { test as base } from '@playwright/test'
import type {
APIRequestContext,
ExpectMatcherState,
Locator,
Page
} from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import { config as dotenvConfig } from 'dotenv'
import { TestIds } from './selectors'
import { NodeBadgeMode } from '../../src/types/nodeSource'
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 { 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 {
AssetsSidebarTab,
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
WorkflowsSidebarTab
} 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'
} 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 type { NodeReference } from './utils/litegraphUtils'
import { assetPath } from './utils/paths'
import type { WorkspaceStore } from '../types/globals'
dotenvConfig()
@@ -58,9 +59,7 @@ 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
@@ -78,21 +77,11 @@ 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
@@ -136,7 +125,7 @@ type KeysOfType<T, Match> = {
}[keyof T]
class ConfirmDialog {
public readonly root: Locator
private readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
@@ -211,10 +200,9 @@ export class ComfyPage {
public readonly featureFlags: FeatureFlagHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly queue: QueueHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -259,10 +247,9 @@ export class ComfyPage {
this.featureFlags = new FeatureFlagHelper(page)
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
this.queue = new QueueHelper(page)
}
get visibleToasts() {
@@ -373,7 +360,7 @@ export class ComfyPage {
}
async delay(ms: number) {
return sleep(ms)
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
@@ -491,4 +478,49 @@ export const comfyPageFixture = base.extend<{
}
})
export { comfyExpect }
const makeMatcher = function <T>(
getValue: (node: NodeReference) => Promise<T> | T,
type: string
) {
return async function (
this: ExpectMatcherState,
node: NodeReference,
options?: { timeout?: number; intervals?: number[] }
) {
const value = await getValue(node)
let assertion = expect(
value,
'Node is ' + (this.isNot ? '' : 'not ') + type
)
if (this.isNot) {
assertion = assertion.not
}
await expect(async () => {
assertion.toBeTruthy()
}).toPass({ timeout: 250, ...options })
return {
pass: !this.isNot,
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
}
}
}
export const comfyExpect = expect.extend({
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
const isFocused = await locator.evaluate(
(el) => el === document.activeElement
)
await expect(async () => {
expect(isFocused).toBe(!this.isNot)
}).toPass(options)
return {
pass: isFocused,
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
}
}
})

View File

@@ -1,24 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { comfyExpect as expect } from '../ComfyPage'
import { TestIds } from '../selectors'
export class QueuePanel {
readonly overlayToggle: Locator
readonly moreOptionsButton: Locator
constructor(readonly page: Page) {
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
this.moreOptionsButton = page.getByLabel(/More options/i).first()
}
async openClearHistoryDialog() {
await this.moreOptionsButton.click()
const clearHistoryAction = this.page.getByTestId(
TestIds.queue.clearHistoryAction
)
await expect(clearHistoryAction).toBeVisible()
await clearHistoryAction.click()
}
}

View File

@@ -1,5 +1,4 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
import { TestIds } from '../selectors'
@@ -100,59 +99,6 @@ 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')
@@ -223,70 +169,11 @@ 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')
}
get root() {
return this.page.locator('.sidebar-content-container')
}
// --- Tab navigation ---
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
@@ -295,292 +182,18 @@ 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'
)
}
get searchInput() {
return this.root.getByPlaceholder(/Search Assets/i)
}
get settingsButton() {
return this.root.getByLabel('View settings')
}
get viewSettingsButton() {
return this.settingsButton
}
get listViewOption() {
return this.page.getByText('List view')
}
get listViewButton() {
return this.listViewOption
}
get gridViewOption() {
return this.page.getByText('Grid view')
}
get gridViewButton() {
return this.gridViewOption
}
get backButton() {
return this.page.getByRole('button', { name: 'Back to all assets' })
}
get copyJobIdButton() {
return this.page.getByRole('button', { name: 'Copy job ID' })
}
get previewDialog() {
return this.page.getByRole('dialog', { name: 'Gallery' })
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
previewImage(filename: string) {
return this.previewDialog.getByRole('img', { name: filename })
}
asset(name: string) {
return this.getAssetCardByName(name)
}
// --- 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.root.locator('[role="button"][data-selected]')
}
getAssetCardByName(name: string) {
return this.assetCards.filter({ hasText: name }).first()
}
get selectedCards() {
return this.root.locator('[data-selected="true"]')
}
// --- List view items ---
get listViewItems() {
return this.root.locator('[role="button"][tabindex="0"]')
}
// --- Selection footer ---
get selectionFooter() {
return this.root.locator('..').locator('[class*="h-18"]')
}
get selectionCountButton() {
return this.root
.getByRole('button', { name: /Assets Selected:/ })
.or(this.page.getByText(/Assets Selected: \d+/))
.first()
}
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 deleteSelectionButton() {
return this.deleteSelectedButton
}
get downloadSelectedButton() {
return this.page
.getByTestId('assets-download-selected')
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
}
get downloadSelectionButton() {
return this.downloadSelectedButton
}
// --- Context menu ---
contextMenuItem(label: string) {
return this.page.locator('.p-contextmenu').getByText(label)
}
contextMenuAction(label: string) {
return this.contextMenuItem(label)
}
// --- Folder view ---
get backToAssetsButton() {
return this.backButton
}
// --- Loading ---
get skeletonLoaders() {
return this.root.locator('.animate-pulse')
}
async showGenerated() {
await this.switchToGenerated()
}
async showImported() {
await this.switchToImported()
}
async search(query: string) {
await this.searchInput.fill(query)
}
async switchToListView() {
await this.openSettingsMenu()
await this.listViewOption.click()
}
async switchToGridView() {
await this.openSettingsMenu()
await this.gridViewOption.click()
}
async openContextMenuForAsset(name: string) {
await this.asset(name).click({ button: 'right' })
await this.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
}
async runContextMenuAction(assetName: string, actionName: string) {
await this.openContextMenuForAsset(assetName)
await this.contextMenuAction(actionName).click()
}
async openAssetPreview(name: string) {
const asset = this.asset(name)
await asset.hover()
const zoomButton = asset.getByLabel('Zoom in')
if (await zoomButton.isVisible().catch(() => false)) {
await zoomButton.click()
return
}
await asset.dblclick()
}
async openOutputFolder(name: string) {
await this.asset(name)
.getByRole('button', { name: 'See more outputs' })
.click()
await this.backToAssetsButton.waitFor({ state: 'visible' })
}
async toggleStack(name: string) {
await this.asset(name)
.getByRole('button', { name: 'See more outputs' })
.click()
}
async selectAssets(names: string[]) {
if (names.length === 0) {
return
}
await this.asset(names[0]).click()
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
for (const name of names.slice(1)) {
await this.asset(name).click({
modifiers: [modifier]
})
}
}
override async open() {
// Remove any toast notifications that may overlay the sidebar button
await this.dismissToasts()
await super.open()
await this.root.waitFor({ state: 'visible' })
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 })
}
}
}

View File

@@ -1,77 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { BaseDialog } from './BaseDialog'
export class SignInDialog extends BaseDialog {
readonly emailInput: Locator
readonly passwordInput: Locator
readonly signInButton: Locator
readonly forgotPasswordLink: Locator
readonly apiKeyButton: Locator
readonly termsLink: Locator
readonly privacyLink: Locator
constructor(page: Page) {
super(page)
this.emailInput = this.root.locator('#comfy-org-sign-in-email')
this.passwordInput = this.root.locator('#comfy-org-sign-in-password')
this.signInButton = this.root.getByRole('button', { name: 'Sign in' })
this.forgotPasswordLink = this.root.getByText('Forgot password?')
this.apiKeyButton = this.root.getByRole('button', {
name: 'Comfy API Key'
})
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
}
async open() {
await this.page.evaluate(() => {
void window.app!.extensionManager.dialog.showSignInDialog()
})
await this.waitForVisible()
}
get heading() {
return this.root.getByRole('heading').first()
}
get signUpLink() {
return this.root.getByText('Sign up', { exact: true })
}
get signInLink() {
return this.root.getByText('Sign in', { exact: true })
}
get signUpEmailInput() {
return this.root.locator('#comfy-org-sign-up-email')
}
get signUpPasswordInput() {
return this.root.locator('#comfy-org-sign-up-password')
}
get signUpConfirmPasswordInput() {
return this.root.locator('#comfy-org-sign-up-confirm-password')
}
get signUpButton() {
return this.root.getByRole('button', { name: 'Sign up', exact: true })
}
get apiKeyHeading() {
return this.root.getByRole('heading', { name: 'API Key' })
}
get apiKeyInput() {
return this.root.locator('#comfy-org-api-key')
}
get backButton() {
return this.root.getByRole('button', { name: 'Back' })
}
get dividerText() {
return this.root.getByText('Or continue with')
}
}

View File

@@ -50,30 +50,15 @@ 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.hover()
await tab.locator('.close-button').click({ force: true })
await tab.getByRole('button', { name: 'Close' }).click({ force: true })
}
getSaveDialog(): Locator {

View File

@@ -1,41 +0,0 @@
# Mock Data Fixtures
Deterministic mock data for browser (Playwright) tests. Each fixture
exports typed objects that conform to generated types from
`packages/ingest-types` or Zod schemas in `src/schemas/`.
## Usage with `page.route()`
> **Note:** `comfyPageFixture` navigates to the app during `setup()`,
> before the test body runs. Routes must be registered before navigation
> to intercept initial page-load requests. Set up routes in a custom
> fixture or `test.beforeEach` that runs before `comfyPage.setup()`.
```ts
import { createMockNodeDefinitions } from '../fixtures/data/nodeDefinitions'
import { mockSystemStats } from '../fixtures/data/systemStats'
// Extend the base set with test-specific nodes
const nodeDefs = createMockNodeDefinitions({
MyCustomNode: {
/* ... */
}
})
await page.route('**/api/object_info', (route) =>
route.fulfill({ json: nodeDefs })
)
await page.route('**/api/system_stats', (route) =>
route.fulfill({ json: mockSystemStats })
)
```
## Adding new fixtures
1. Locate the generated type in `packages/ingest-types` or Zod schema
in `src/schemas/` for the endpoint you need.
2. Create a new `.ts` file here that imports and satisfies the
corresponding TypeScript type.
3. Keep values realistic but stable — avoid dates, random IDs, or
values that would cause test flakiness.

View File

@@ -1,155 +0,0 @@
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
/**
* Base node definitions covering the default workflow.
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
*/
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
KSampler: {
input: {
required: {
model: ['MODEL', {}],
seed: [
'INT',
{
default: 0,
min: 0,
max: 0xfffffffffffff,
control_after_generate: true
}
],
steps: ['INT', { default: 20, min: 1, max: 10000 }],
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
positive: ['CONDITIONING', {}],
negative: ['CONDITIONING', {}],
latent_image: ['LATENT', {}]
},
optional: {
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
}
},
output: ['LATENT'],
output_is_list: [false],
output_name: ['LATENT'],
name: 'KSampler',
display_name: 'KSampler',
description: 'Samples latents using the provided model and conditioning.',
category: 'sampling',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
CheckpointLoaderSimple: {
input: {
required: {
ckpt_name: [
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
{}
]
}
},
output: ['MODEL', 'CLIP', 'VAE'],
output_is_list: [false, false, false],
output_name: ['MODEL', 'CLIP', 'VAE'],
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
description: 'Loads a diffusion model checkpoint.',
category: 'loaders',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
CLIPTextEncode: {
input: {
required: {
text: ['STRING', { multiline: true, dynamicPrompts: true }],
clip: ['CLIP', {}]
}
},
output: ['CONDITIONING'],
output_is_list: [false],
output_name: ['CONDITIONING'],
name: 'CLIPTextEncode',
display_name: 'CLIP Text Encode (Prompt)',
description: 'Encodes a text prompt using a CLIP model.',
category: 'conditioning',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
EmptyLatentImage: {
input: {
required: {
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
}
},
output: ['LATENT'],
output_is_list: [false],
output_name: ['LATENT'],
name: 'EmptyLatentImage',
display_name: 'Empty Latent Image',
description: 'Creates an empty latent image of the specified dimensions.',
category: 'latent',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
VAEDecode: {
input: {
required: {
samples: ['LATENT', {}],
vae: ['VAE', {}]
}
},
output: ['IMAGE'],
output_is_list: [false],
output_name: ['IMAGE'],
name: 'VAEDecode',
display_name: 'VAE Decode',
description: 'Decodes latent images back into pixel space.',
category: 'latent',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
SaveImage: {
input: {
required: {
images: ['IMAGE', {}],
filename_prefix: ['STRING', { default: 'ComfyUI' }]
}
},
output: [],
output_is_list: [],
output_name: [],
name: 'SaveImage',
display_name: 'Save Image',
description: 'Saves images to the output directory.',
category: 'image',
output_node: true,
python_module: 'nodes',
deprecated: false,
experimental: false
}
}
export function createMockNodeDefinitions(
overrides?: Record<string, ComfyNodeDef>
): Record<string, ComfyNodeDef> {
const base = structuredClone(baseNodeDefinitions)
return overrides ? { ...base, ...overrides } : base
}

View File

@@ -1,22 +0,0 @@
import type { SystemStatsResponse } from '@comfyorg/ingest-types'
export const mockSystemStats: SystemStatsResponse = {
system: {
os: 'posix',
python_version: '3.11.9 (main, Apr 2 2024, 08:25:04) [GCC 13.2.0]',
embedded_python: false,
comfyui_version: '0.3.10',
pytorch_version: '2.4.0+cu124',
argv: ['main.py', '--listen', '0.0.0.0'],
ram_total: 67108864000,
ram_free: 52428800000
},
devices: [
{
name: 'NVIDIA GeForce RTX 4090',
type: 'cuda',
vram_total: 25769803776,
vram_free: 23622320128
}
]
}

View File

@@ -1,436 +1,112 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import type { Page, Route } from '@playwright/test'
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { ResultItemType, TaskOutput } from '../../../src/schemas/apiSchema'
import { JobsApiMock } from './JobsApiMock'
import type { SeededJob } from './JobsApiMock'
import { getMimeType } from './mimeTypeUtil'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
const helperDir = path.dirname(fileURLToPath(import.meta.url))
type SeededAssetFile = {
filePath?: string
contentType?: string
textContent?: string
}
type MockPreviewOutput = NonNullable<JobEntry['preview_output']> & {
filename?: string
subfolder?: string
type?: ResultItemType
nodeId: string
mediaType?: string
display_name?: string
}
/** Factory to create a mock completed job with preview output. */
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
export function createMockJobs(
count: number,
baseOverrides?: Partial<JobEntry>
): JobEntry[] {
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 + (5 + i) * 1_000,
preview_output: {
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
...baseOverrides
})
)
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
/** 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]}`
)
}
export type ImportedAssetSeed = {
name: string
filePath?: string
contentType?: string
}
export type GeneratedAssetOutputSeed = {
filename: string
displayName?: string
filePath?: string
contentType?: string
mediaType?: 'images' | 'video' | 'audio'
subfolder?: string
type?: ResultItemType
}
export type GeneratedJobSeed = {
jobId: string
outputs: [GeneratedAssetOutputSeed, ...GeneratedAssetOutputSeed[]]
createdAt?: string
createTime?: number
executionStartTime?: number
executionEndTime?: number
workflowId?: string
workflow?: JobDetailResponse['workflow']
nodeId?: string
}
function getFixturePath(relativePath: string): string {
return path.resolve(helperDir, '../../assets', relativePath)
}
function defaultFileFor(filename: string): SeededAssetFile {
const name = filename.toLowerCase()
if (name.endsWith('.png')) {
return {
filePath: getFixturePath('workflowInMedia/workflow_itxt.png'),
contentType: 'image/png'
}
}
if (name.endsWith('.webp')) {
return {
filePath: getFixturePath('example.webp'),
contentType: 'image/webp'
}
}
if (name.endsWith('.webm')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.webm'),
contentType: 'video/webm'
}
}
if (name.endsWith('.mp4')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
contentType: 'video/mp4'
}
}
if (name.endsWith('.glb')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.glb'),
contentType: 'model/gltf-binary'
}
}
if (name.endsWith('.json')) {
return {
textContent: JSON.stringify({ mocked: true }, null, 2),
contentType: 'application/json'
}
}
return {
textContent: 'mocked asset content',
contentType: getMimeType(filename)
}
}
function normalizeOutputSeed(
output: GeneratedAssetOutputSeed
): GeneratedAssetOutputSeed {
const fallback = defaultFileFor(output.filename)
return {
mediaType: 'images',
subfolder: '',
type: 'output',
...output,
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType
}
}
function buildTaskOutput(
jobSeed: GeneratedJobSeed,
outputs: GeneratedAssetOutputSeed[]
): TaskOutput {
const nodeId = jobSeed.nodeId ?? '5'
return {
[nodeId]: {
[outputs[0].mediaType ?? 'images']: outputs.map((output) => ({
filename: output.filename,
subfolder: output.subfolder ?? '',
type: output.type ?? 'output',
display_name: output.displayName
}))
}
}
}
function buildSeededJob(jobSeed: GeneratedJobSeed): SeededJob {
const outputs = jobSeed.outputs.map(normalizeOutputSeed)
const preview = outputs[0]
const createTime =
jobSeed.createTime ??
new Date(jobSeed.createdAt ?? '2026-03-27T12:00:00.000Z').getTime()
const executionStartTime = jobSeed.executionStartTime ?? createTime
const executionEndTime = jobSeed.executionEndTime ?? createTime + 2_000
const listItem: JobEntry = {
id: jobSeed.jobId,
status: 'completed',
create_time: createTime,
execution_start_time: executionStartTime,
execution_end_time: executionEndTime,
preview_output: {
filename: preview.filename,
subfolder: preview.subfolder ?? '',
type: preview.type ?? 'output',
nodeId: jobSeed.nodeId ?? '5',
mediaType: preview.mediaType ?? 'images',
display_name: preview.displayName
},
outputs_count: outputs.length,
...(jobSeed.workflowId ? { workflow_id: jobSeed.workflowId } : {})
}
const detail: JobDetailResponse = {
...listItem,
workflow: jobSeed.workflow,
outputs: buildTaskOutput(jobSeed, outputs),
update_time: executionEndTime
}
return { listItem, detail }
}
function createOutputFilename(baseFilename: string, index: number): string {
if (index === 0) {
return baseFilename
}
const extensionIndex = baseFilename.lastIndexOf('.')
if (extensionIndex === -1) {
return `${baseFilename}-${index + 1}`
}
return `${baseFilename.slice(0, extensionIndex)}-${index + 1}${baseFilename.slice(extensionIndex)}`
}
function getPreviewOutput(
previewOutput: JobEntry['preview_output'] | undefined
): MockPreviewOutput | undefined {
return previewOutput as MockPreviewOutput | undefined
}
function outputsFromJobEntry(
job: JobEntry
): [GeneratedAssetOutputSeed, ...GeneratedAssetOutputSeed[]] {
const previewOutput = getPreviewOutput(job.preview_output)
const outputCount = Math.max(job.outputs_count ?? 1, 1)
const baseFilename = previewOutput?.filename ?? `output_${job.id}.png`
const mediaType: GeneratedAssetOutputSeed['mediaType'] =
previewOutput?.mediaType === 'video' || previewOutput?.mediaType === 'audio'
? previewOutput.mediaType
: 'images'
const outputs = Array.from({ length: outputCount }, (_, index) => ({
filename: createOutputFilename(baseFilename, index),
displayName: index === 0 ? previewOutput?.display_name : undefined,
mediaType,
subfolder: previewOutput?.subfolder ?? '',
type: previewOutput?.type ?? 'output'
}))
return [outputs[0], ...outputs.slice(1)]
}
function generatedJobFromJobEntry(job: JobEntry): GeneratedJobSeed {
return {
jobId: job.id,
outputs: outputsFromJobEntry(job),
createTime: job.create_time,
executionStartTime: job.execution_start_time,
executionEndTime: job.execution_end_time,
workflowId: job.workflow_id
}
function getExecutionDuration(job: RawJobListItem): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
export class AssetsHelper {
private readonly jobsApiMock: JobsApiMock
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
private generatedJobs: GeneratedJobSeed[] = []
private importedFiles: ImportedAssetSeed[] = []
private seededFiles = new Map<string, SeededAssetFile>()
private generatedJobs: RawJobListItem[] = []
private importedFiles: string[] = []
constructor(private readonly page: Page) {
this.jobsApiMock = new JobsApiMock(page)
}
constructor(private readonly page: Page) {}
generatedImage(
options: Partial<Omit<GeneratedJobSeed, 'outputs'>> & {
filename: string
displayName?: string
filePath?: string
contentType?: string
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
this.generatedJobs = [...jobs]
if (this.jobsRouteHandler) {
return
}
): GeneratedJobSeed {
const {
filename,
displayName,
filePath,
contentType,
jobId = `job-${filename.replace(/\W+/g, '-').toLowerCase()}`,
...rest
} = options
return {
jobId,
outputs: [
{
filename,
displayName,
filePath,
contentType,
mediaType: 'images'
}
],
...rest
}
}
this.jobsRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
importedImage(options: ImportedAssetSeed): ImportedAssetSeed {
return { ...options }
}
let filteredJobs = [...this.generatedJobs]
async workflowContainerFromFixture(
relativePath: string = 'default.json'
): Promise<JobDetailResponse['workflow']> {
const workflow = JSON.parse(
await readFile(getFixturePath(relativePath), 'utf-8')
)
return {
extra_data: {
extra_pnginfo: {
workflow
}
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
}
}
async seedAssets({
generated = [],
imported = []
}: {
generated?: GeneratedJobSeed[]
imported?: ImportedAssetSeed[]
}): Promise<void> {
this.generatedJobs = [...generated]
this.importedFiles = [...imported]
this.seededFiles = new Map()
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
for (const job of this.generatedJobs) {
for (const output of job.outputs) {
const fallback = defaultFileFor(output.filename)
this.seededFiles.set(output.filename, {
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType,
textContent: fallback.textContent
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
}
}
for (const asset of this.importedFiles) {
const fallback = defaultFileFor(asset.name)
this.seededFiles.set(asset.name, {
filePath: asset.filePath ?? fallback.filePath,
contentType: asset.contentType ?? fallback.contentType,
textContent: fallback.textContent
})
}
await this.jobsApiMock.seedJobs(this.generatedJobs.map(buildSeededJob))
await this.ensureInputFilesRoute()
await this.ensureViewRoute()
}
async mockOutputHistory(jobs: JobEntry[]): Promise<void> {
await this.seedAssets({
generated: jobs.map(generatedJobFromJobEntry),
imported: this.importedFiles
})
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockInputFiles(files: string[]): Promise<void> {
await this.seedAssets({
generated: this.generatedJobs,
imported: files.map((name) => ({ name }))
})
}
this.importedFiles = [...files]
async mockEmptyState(): Promise<void> {
await this.seedAssets({ generated: [], imported: [] })
}
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
this.seededFiles.clear()
await this.jobsApiMock.clearMocks()
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
if (this.viewRouteHandler) {
await this.page.unroute(viewRoutePattern, this.viewRouteHandler)
this.viewRouteHandler = null
}
}
private async ensureInputFilesRoute(): Promise<void> {
if (this.inputFilesRouteHandler) {
return
}
@@ -439,51 +115,33 @@ export class AssetsHelper {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles.map((asset) => asset.name))
body: JSON.stringify(this.importedFiles)
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
private async ensureViewRoute(): Promise<void> {
if (this.viewRouteHandler) {
return
async mockEmptyState(): Promise<void> {
await this.mockOutputHistory([])
await this.mockInputFiles([])
}
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
if (this.jobsRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
this.jobsRouteHandler = null
}
this.viewRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename')
if (!filename) {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'Missing filename' })
})
return
}
const seededFile =
this.seededFiles.get(filename) ?? defaultFileFor(filename)
if (seededFile.filePath) {
const body = await readFile(seededFile.filePath)
await route.fulfill({
status: 200,
contentType: seededFile.contentType ?? getMimeType(filename),
body
})
return
}
await route.fulfill({
status: 200,
contentType: seededFile.contentType ?? getMimeType(filename),
body: seededFile.textContent ?? ''
})
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
await this.page.route(viewRoutePattern, this.viewRouteHandler)
}
}

View File

@@ -30,10 +30,6 @@ 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)
}

View File

@@ -1,196 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
export type SeededJob = {
listItem: JobEntry
detail: JobDetailResponse
}
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
function getExecutionDuration(job: JobEntry): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
export class JobsApiMock {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private seededJobs: SeededJob[] = []
constructor(private readonly page: Page) {}
async seedJobs(jobs: SeededJob[]): Promise<void> {
this.seededJobs = [...jobs]
await this.ensureRoutesRegistered()
}
async clearMocks(): Promise<void> {
this.seededJobs = []
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
let filteredJobs = this.seededJobs.map(({ listItem }) => listItem)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
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 JobsListResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = route
.request()
.url()
.split('/api/jobs/')[1]
?.split('?')[0]
const job = jobId
? this.seededJobs.find(({ listItem }) => listItem.id === jobId)
: undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const requestBody = route.request().postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.seededJobs = this.seededJobs.filter(
({ listItem }) =>
listItem.status === 'pending' || listItem.status === 'in_progress'
)
}
if (requestBody?.delete?.length) {
const deletedIds = new Set(requestBody.delete)
this.seededJobs = this.seededJobs.filter(
({ listItem }) => !deletedIds.has(listItem.id)
)
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -14,9 +14,8 @@ export class KeyboardHelper {
keyToPress: string,
locator: Locator | null = this.canvas
): Promise<void> {
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
const target = locator ?? this.page.keyboard
await target.press(`${modifier}+${keyToPress}`)
await target.press(`Control+${keyToPress}`)
await this.nextFrame()
}

View File

@@ -1,134 +0,0 @@
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
}
}
}

View File

@@ -0,0 +1,79 @@
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
}
}
}

View File

@@ -147,16 +147,6 @@ export class WorkflowHelper {
})
}
async waitForWorkflowIdle(timeout = 5000): Promise<void> {
await this.comfyPage.page.waitForFunction(
() =>
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy,
undefined,
{ timeout }
)
}
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
async getExportedWorkflow(options?: {
api?: false

View File

@@ -44,15 +44,7 @@ export const TestIds = {
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
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'
missingModelsGroup: 'error-group-missing-model'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -69,21 +61,8 @@ 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',
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image'
titleInput: 'node-title-input'
},
selectionToolbox: {
colorPickerButton: 'color-picker-button',
@@ -91,9 +70,6 @@ export const TestIds = {
colorBlue: 'blue',
colorRed: 'red'
},
menu: {
moreMenuContent: 'more-menu-content'
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
@@ -106,7 +82,6 @@ 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',
@@ -123,10 +98,6 @@ export const TestIds = {
user: {
currentUserIndicator: 'current-user-indicator'
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
@@ -155,7 +126,4 @@ 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]

View File

@@ -1,49 +0,0 @@
import type { ExpectMatcherState, Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { NodeReference } from './litegraphUtils'
function makeMatcher<T>(
getValue: (node: NodeReference) => Promise<T> | T,
type: string
) {
return async function (
this: ExpectMatcherState,
node: NodeReference,
options?: { timeout?: number; intervals?: number[] }
) {
await expect(async () => {
const value = await getValue(node)
const assertion = this.isNot
? expect(value, 'Node is ' + type).not
: expect(value, 'Node is not ' + type)
assertion.toBeTruthy()
}).toPass({ timeout: 250, ...options })
return {
pass: !this.isNot,
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
}
}
}
export const comfyExpect = expect.extend({
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
await expect
.poll(
() => locator.evaluate((el) => el === document.activeElement),
options
)
.toBe(!this.isNot)
const isFocused = await locator.evaluate(
(el) => el === document.activeElement
)
return {
pass: isFocused,
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
}
}
})

View File

@@ -1,98 +0,0 @@
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}`)
}

View File

@@ -1,3 +0,0 @@
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -1,7 +1,5 @@
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) {}
@@ -22,10 +20,6 @@ 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"]')
}

View File

@@ -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 '@e2e/fixtures/ComfyPage'
import { webSocketFixture } from '@e2e/fixtures/ws'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import type { WorkspaceStore } from '../types/globals'
const test = mergeTests(comfyPageFixture, webSocketFixture)

View File

@@ -189,41 +189,6 @@ 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
}) => {

View File

@@ -1,68 +0,0 @@
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)
})
}
)

View File

@@ -1,126 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Queue Clear History Dialog', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.queuePanel.overlayToggle.click()
})
test('Dialog opens from queue panel history actions menu', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
})
test('Dialog shows confirmation message with title, description, and assets note', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('Clear your job queue history?')
).toBeVisible()
await expect(
dialog.getByText(
'All the finished or failed jobs below will be removed from this Job queue panel.'
)
).toBeVisible()
await expect(
dialog.getByText(
'Assets generated by these jobs won\u2019t be deleted and can always be viewed from the assets panel.'
)
).toBeVisible()
})
test('Cancel button closes dialog without clearing history', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
let clearCalled = false
await comfyPage.page.route('**/api/history', (route) => {
if (route.request().method() === 'POST') {
clearCalled = true
}
return route.continue()
})
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
})
test('Close (X) button closes dialog without clearing history', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
let clearCalled = false
await comfyPage.page.route('**/api/history', (route) => {
if (route.request().method() === 'POST') {
clearCalled = true
}
return route.continue()
})
await dialog.getByLabel('Close').click()
await expect(dialog).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
})
test('Confirm clears queue history and closes dialog', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
const clearPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes('/api/history') && req.method() === 'POST'
)
await dialog.getByRole('button', { name: 'Clear' }).click()
const request = await clearPromise
expect(request.postDataJSON()).toEqual({ clear: true })
await expect(dialog).not.toBeVisible()
})
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).not.toBeVisible()
await comfyPage.queuePanel.openClearHistoryDialog()
await expect(dialog).toBeVisible()
const clearButton = dialog.getByRole('button', { name: 'Clear' })
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeEnabled()
})
})

View File

@@ -1,94 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { SignInDialog } from '../../fixtures/components/SignInDialog'
test.describe('Sign In dialog', { tag: '@ui' }, () => {
let dialog: SignInDialog
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
dialog = new SignInDialog(comfyPage.page)
await dialog.open()
})
test('Should open and show the sign-in form by default', async () => {
await expect(
dialog.root.getByRole('heading', { name: 'Log in to your account' })
).toBeVisible()
await expect(dialog.emailInput).toBeVisible()
await expect(dialog.passwordInput).toBeVisible()
await expect(dialog.signInButton).toBeVisible()
})
test('Should toggle from sign-in to sign-up form', async () => {
await dialog.signUpLink.click()
await expect(
dialog.root.getByRole('heading', { name: 'Create an account' })
).toBeVisible()
await expect(dialog.signUpEmailInput).toBeVisible()
await expect(dialog.signUpPasswordInput).toBeVisible()
await expect(dialog.signUpConfirmPasswordInput).toBeVisible()
await expect(dialog.signUpButton).toBeVisible()
})
test('Should toggle back from sign-up to sign-in form', async () => {
await dialog.signUpLink.click()
await expect(
dialog.root.getByRole('heading', { name: 'Create an account' })
).toBeVisible()
await dialog.signInLink.click()
await expect(
dialog.root.getByRole('heading', { name: 'Log in to your account' })
).toBeVisible()
await expect(dialog.emailInput).toBeVisible()
await expect(dialog.passwordInput).toBeVisible()
})
test('Should navigate to the API Key form and back', async () => {
await dialog.apiKeyButton.click()
await expect(dialog.apiKeyHeading).toBeVisible()
await expect(dialog.apiKeyInput).toBeVisible()
await dialog.backButton.click()
await expect(
dialog.root.getByRole('heading', { name: 'Log in to your account' })
).toBeVisible()
})
test('Should display Terms of Service and Privacy Policy links', async () => {
await expect(dialog.termsLink).toBeVisible()
await expect(dialog.termsLink).toHaveAttribute(
'href',
'https://www.comfy.org/terms-of-service'
)
await expect(dialog.privacyLink).toBeVisible()
await expect(dialog.privacyLink).toHaveAttribute(
'href',
'https://www.comfy.org/privacy'
)
})
test('Should display the "Or continue with" divider and API key button', async () => {
await expect(dialog.dividerText).toBeVisible()
await expect(dialog.apiKeyButton).toBeVisible()
})
test('Should show forgot password link on sign-in form', async () => {
await expect(dialog.forgotPasswordLink).toBeVisible()
})
test('Should close dialog via close button', async () => {
await dialog.close()
await expect(dialog.root).toBeHidden()
})
test('Should close dialog via Escape key', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).toBeHidden()
})
})

View File

@@ -0,0 +1,429 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
// ---- Helpers ---------------------------------------------------------------
function createTestImageDataUrl(
width: number,
height: number,
color: string
): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
`<rect width="${width}" height="${height}" fill="${color}"/>` +
`</svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
type Bounds = { x: number; y: number; width: number; height: number }
/**
* Injects an image into the node output store for the given source node,
* simulating what happens after a node executes and produces image output.
*/
async function injectSourceImage(
page: Page,
sourceNodeId: number,
dataUrl: string
): Promise<void> {
await page.evaluate(
({ nodeId, url }) => {
type NodeOutputStore = {
setNodePreviewsByNodeId: (id: number, urls: string[]) => void
}
type VueAppElement = HTMLElement & {
__vue_app__: {
config: {
globalProperties: {
$pinia: { _s: Map<string, NodeOutputStore> }
}
}
}
}
const el = document.getElementById('vue-app') as unknown as VueAppElement
const store =
el.__vue_app__.config.globalProperties.$pinia._s.get('nodeOutput')!
store.setNodePreviewsByNodeId(nodeId, [url])
},
{ nodeId: sourceNodeId, url: dataUrl }
)
}
async function setCropState(page: Page, bounds: Bounds): Promise<void> {
await page.evaluate((bounds) => {
type BoundsValue = { x: number; y: number; width: number; height: number }
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
if (widget?.value) {
const value = widget.value as unknown as BoundsValue
value.x = bounds.x
value.y = bounds.y
value.width = bounds.width
value.height = bounds.height
widget.callback?.(widget.value)
}
}, bounds)
}
async function getCropState(page: Page): Promise<Bounds> {
return page.evaluate(() => {
type BoundsValue = { x: number; y: number; width: number; height: number }
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
const v = widget?.value as unknown as BoundsValue
return { x: v.x, y: v.y, width: v.width, height: v.height }
})
}
/**
* Injects a test image into the source node and waits for the crop widget to
* be fully ready: image loaded, scale factor computed, crop box visible.
*/
async function setupWithImage(
comfyPage: ComfyPage,
imageWidth: number,
imageHeight: number,
initialBounds: Bounds
): Promise<void> {
await injectSourceImage(
comfyPage.page,
2,
createTestImageDataUrl(imageWidth, imageHeight, 'steelblue')
)
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toBeVisible()
await comfyPage.page.waitForFunction(() => {
const img = document.querySelector(
'[data-node-id="1"] img'
) as HTMLImageElement | null
return (img?.complete ?? false) && (img?.naturalWidth ?? 0) > 0
})
await setCropState(comfyPage.page, initialBounds)
await comfyPage.nextFrame()
await expect(node.locator('.cursor-move')).toBeVisible()
}
/**
* Returns a locator for one of the 8 resize handles on the crop widget.
* Handles are ordered in DOM as: top, bottom, left, right, nw, ne, sw, se.
*/
function getResizeHandle(
nodeLocator: Locator,
direction: 'top' | 'bottom' | 'left' | 'right' | 'nw' | 'ne' | 'sw' | 'se'
): Locator {
switch (direction) {
case 'top':
return nodeLocator.locator('.cursor-ns-resize').first()
case 'bottom':
return nodeLocator.locator('.cursor-ns-resize').last()
case 'left':
return nodeLocator.locator('.cursor-ew-resize').first()
case 'right':
return nodeLocator.locator('.cursor-ew-resize').last()
case 'nw':
return nodeLocator.locator('.cursor-nwse-resize').first()
case 'se':
return nodeLocator.locator('.cursor-nwse-resize').last()
case 'ne':
return nodeLocator.locator('.cursor-nesw-resize').first()
case 'sw':
return nodeLocator.locator('.cursor-nesw-resize').last()
}
}
async function dragFrom(
page: Page,
locator: Locator,
deltaX: number,
deltaY: number
): Promise<void> {
const box = await locator.boundingBox()
expect(box).not.toBeNull()
const startX = box!.x + box!.width / 2
const startY = box!.y + box!.height / 2
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX + deltaX, startY + deltaY, { steps: 10 })
await page.mouse.up()
}
// ---- Tests -----------------------------------------------------------------
test.describe('Image Crop', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'shows empty state when no input image is available',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node).toContainText('No input image connected')
await expect(node.locator('.cursor-move')).not.toBeVisible()
}
)
test(
'enforces minimum node size of 300×450',
{ tag: '@node' },
async ({ comfyPage }) => {
const size = await comfyPage.page.evaluate(() => {
const node = window.app!.graph.getNodeById(1)
return node?.size as [number, number]
})
expect(size[0]).toBeGreaterThanOrEqual(300)
expect(size[1]).toBeGreaterThanOrEqual(450)
}
)
test.describe('drag', { tag: '@widget' }, () => {
test('moves the crop box', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const cropBox = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.cursor-move')
await dragFrom(comfyPage.page, cropBox, 100, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeGreaterThan(100)
expect(state.y).toBe(100)
expect(state.width).toBe(200)
expect(state.height).toBe(200)
})
test('clamps to right boundary', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 550,
y: 100,
width: 200,
height: 200
})
const cropBox = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.cursor-move')
await dragFrom(comfyPage.page, cropBox, 500, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x + state.width).toBeLessThanOrEqual(800)
})
test('clamps to top-left boundary', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 50,
y: 50,
width: 200,
height: 200
})
const cropBox = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.cursor-move')
await dragFrom(comfyPage.page, cropBox, -500, -500)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeGreaterThanOrEqual(0)
expect(state.y).toBeGreaterThanOrEqual(0)
})
test('does nothing when no image is loaded', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('.cursor-move')).not.toBeVisible()
const stateBefore = await getCropState(comfyPage.page)
const nodeBox = await node.boundingBox()
if (nodeBox) {
await comfyPage.page.mouse.click(
nodeBox.x + nodeBox.width / 2,
nodeBox.y + nodeBox.height / 2
)
}
await comfyPage.nextFrame()
const stateAfter = await getCropState(comfyPage.page)
expect(stateAfter).toEqual(stateBefore)
})
})
test.describe('free resize', { tag: '@widget' }, () => {
test('right edge increases width', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), 80, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.width).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.y).toBe(100)
expect(state.height).toBe(200)
})
test('left edge adjusts x and width', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 200,
y: 100,
width: 300,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'left'), -80, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeLessThan(200)
expect(state.width).toBeGreaterThan(300)
expect(state.y).toBe(100)
expect(state.height).toBe(200)
})
test('bottom edge increases height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'bottom'), 0, 80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.height).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.y).toBe(100)
expect(state.width).toBe(200)
})
test('top edge adjusts y and height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 200,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'top'), 0, -80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.y).toBeLessThan(200)
expect(state.height).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.width).toBe(200)
})
test('SE corner increases width and height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'se'), 80, 80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.width).toBeGreaterThan(200)
expect(state.height).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.y).toBe(100)
})
test('NW corner adjusts x, y, width, and height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 200,
y: 200,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'nw'), -80, -80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeLessThan(200)
expect(state.y).toBeLessThan(200)
expect(state.width).toBeGreaterThan(200)
expect(state.height).toBeGreaterThan(200)
})
test('enforces minimum crop size of 16px', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 50,
height: 50
})
const node = comfyPage.vueNodes.getNodeLocator('1')
// Drag right edge far left to try to collapse width below the minimum
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), -500, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.width).toBeGreaterThanOrEqual(16)
})
test('clamps resize to image boundary', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 600,
y: 100,
width: 100,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
// Drag right edge far past the image right boundary
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), 500, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x + state.width).toBeLessThanOrEqual(800)
})
test('shows 8 handles when ratio is unlocked', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const handles = node.locator(
'.cursor-ns-resize, .cursor-ew-resize, .cursor-nwse-resize, .cursor-nesw-resize'
)
await expect(handles).toHaveCount(8)
})
})
})

View File

@@ -1,225 +0,0 @@
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)
})
})
})

View File

@@ -1,34 +0,0 @@
# Properties Panel E2E Tests
Tests for the right-side properties panel (`RightSidePanel.vue`).
## Structure
| File | Coverage |
| --------------------------------- | ----------------------------------------------------------- |
| `openClose.spec.ts` | Panel open/close via actionbar and close button |
| `workflowOverview.spec.ts` | No-selection state: tabs, nodes list, global settings |
| `nodeSelection.spec.ts` | Single/multi-node selection, selection changes, tab labels |
| `titleEditing.spec.ts` | Node title editing via pencil icon |
| `searchFiltering.spec.ts` | Widget search and clear |
| `nodeSettings.spec.ts` | Settings tab: node state, color, pinned (requires VueNodes) |
| `infoTab.spec.ts` | Node help content |
| `errorsTab.spec.ts` | Errors tab visibility |
| `propertiesPanelPosition.spec.ts` | Panel position relative to sidebar |
## Shared Helper
`PropertiesPanelHelper.ts` — Encapsulates panel locators and actions. Instantiated in `beforeEach`:
```typescript
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
```
## Conventions
- Tests requiring VueNodes rendering enable it in `beforeEach` via `comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)` and call `comfyPage.vueNodes.waitForNodes()`.
- Verify node state changes via user-facing indicators (text labels like "Bypassed"/"Muted", pin indicator test IDs) rather than internal properties.
- Color changes are verified via `page.evaluate` accessing node properties, per the guidance in `docs/guidance/playwright.md`.

View File

@@ -1,100 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { TestIds } from '../../fixtures/selectors'
export class PropertiesPanelHelper {
readonly root: Locator
readonly panelTitle: Locator
readonly searchBox: Locator
readonly closeButton: Locator
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.closeButton = this.root.locator('button[aria-pressed]')
}
get tabs(): Locator {
return this.root.locator('nav button')
}
getTab(label: string): Locator {
return this.root.locator('nav button', { hasText: label })
}
get titleEditIcon(): Locator {
return this.panelTitle.locator('i[class*="lucide--pencil"]')
}
get titleInput(): Locator {
return this.root.getByTestId(TestIds.node.titleInput)
}
getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator {
return this.root.locator('button', { hasText: state })
}
getColorSwatch(colorName: string): Locator {
return this.root.locator(`[data-testid="${colorName}"]`)
}
get pinnedSwitch(): Locator {
return this.root.locator('[data-p-checked]').first()
}
get subgraphEditButton(): Locator {
return this.root.locator('button:has(i[class*="lucide--settings-2"])')
}
get contentArea(): Locator {
return this.root.locator('.scrollbar-thin')
}
get errorsTabIcon(): Locator {
return this.root.locator('nav i[class*="lucide--octagon-alert"]')
}
get viewAllSettingsButton(): Locator {
return this.root.getByRole('button', { name: /view all settings/i })
}
get collapseToggleButton(): Locator {
return this.root.locator(
'button:has(i[class*="lucide--chevrons-down-up"]), button:has(i[class*="lucide--chevrons-up-down"])'
)
}
async open(actionbar: Locator): Promise<void> {
if (!(await this.root.isVisible())) {
await actionbar.click()
await expect(this.root).toBeVisible()
}
}
async close(): Promise<void> {
if (await this.root.isVisible()) {
await this.closeButton.click()
await expect(this.root).not.toBeVisible()
}
}
async switchToTab(label: string): Promise<void> {
await this.getTab(label).click()
}
async editTitle(newTitle: string): Promise<void> {
await this.titleEditIcon.click()
await this.titleInput.fill(newTitle)
await this.titleInput.press('Enter')
}
async searchWidgets(query: string): Promise<void> {
await this.searchBox.fill(query)
}
async clearSearch(): Promise<void> {
await this.searchBox.fill('')
}
}

View File

@@ -1,31 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Errors tab', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
test('should show Errors tab when errors exist', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nextFrame()
await expect(panel.errorsTabIcon).toBeVisible()
})
test('should not show Errors tab when errors are disabled', async ({
comfyPage
}) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.errorsTabIcon).not.toBeVisible()
})
})

View File

@@ -1,22 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Info tab', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await panel.switchToTab('Info')
})
test('should show node help content', async () => {
await expect(panel.contentArea).toBeVisible()
await expect(
panel.contentArea.getByRole('heading', { name: 'Inputs' })
).toBeVisible()
})
})

View File

@@ -1,126 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Node selection', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
})
test.describe('Single node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
})
test('should show node title in panel header', async () => {
await expect(panel.panelTitle).toContainText('KSampler')
})
test('should show Parameters, Info, and Settings tabs', async () => {
await expect(panel.getTab('Parameters')).toBeVisible()
await expect(panel.getTab('Info')).toBeVisible()
await expect(panel.getTab('Settings')).toBeVisible()
})
test('should not show Nodes tab for single node', async () => {
await expect(panel.getTab('Nodes')).not.toBeVisible()
})
test('should display node widgets in Parameters tab', async () => {
await expect(panel.contentArea.getByText('seed')).toBeVisible()
await expect(panel.contentArea.getByText('steps')).toBeVisible()
})
})
test.describe('Multi-node', () => {
test('should show item count in title', async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.panelTitle).toContainText('3 items selected')
})
test('should list all selected nodes in Parameters tab', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
await expect(
panel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
})
test('should not show Info tab for multi-selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.getTab('Info')).not.toBeVisible()
})
})
test.describe('Selection changes', () => {
test('should update from no selection to node selection', async ({
comfyPage
}) => {
await expect(panel.panelTitle).toContainText('Workflow Overview')
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.panelTitle).toContainText('KSampler')
})
test('should update from node selection back to no selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.panelTitle).toContainText('KSampler')
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
await expect(panel.panelTitle).toContainText('Workflow Overview')
})
test('should update between different single node selections', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.panelTitle).toContainText('KSampler')
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['Empty Latent Image'])
await expect(panel.panelTitle).toContainText('Empty Latent Image')
})
})
test.describe('Tab labels', () => {
test('should show "Parameters" tab for single node', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(panel.getTab('Parameters')).toBeVisible()
})
test('should show "Nodes" tab label for multi-selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.getTab('Nodes')).toBeVisible()
})
})
})

View File

@@ -1,122 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Node settings', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await panel.switchToTab('Settings')
})
test.describe('Node state', () => {
test('should show Normal, Bypass, and Mute state buttons', async () => {
await expect(panel.getNodeStateButton('Normal')).toBeVisible()
await expect(panel.getNodeStateButton('Bypass')).toBeVisible()
await expect(panel.getNodeStateButton('Mute')).toBeVisible()
})
test('should set node to Bypass mode', async ({ comfyPage }) => {
await panel.getNodeStateButton('Bypass').click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
})
test('should set node to Mute mode', async ({ comfyPage }) => {
await panel.getNodeStateButton('Mute').click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByText('Muted')).toBeVisible()
})
test('should restore node to Normal mode', async ({ comfyPage }) => {
await panel.getNodeStateButton('Bypass').click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByText('Bypassed')).toBeVisible()
await panel.getNodeStateButton('Normal').click()
await expect(nodeLocator.getByText('Bypassed')).not.toBeVisible()
await expect(nodeLocator.getByText('Muted')).not.toBeVisible()
})
})
test.describe('Node color', () => {
test('should display color swatches', async () => {
await expect(panel.getColorSwatch('noColor')).toBeVisible()
await expect(panel.getColorSwatch('red')).toBeVisible()
await expect(panel.getColorSwatch('blue')).toBeVisible()
})
test('should apply color to node', async ({ comfyPage }) => {
await panel.getColorSwatch('red').click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const selected = window.app!.canvas.selected_nodes
const node = Object.values(selected)[0]
return node?.color != null
})
)
.toBe(true)
})
test('should remove color with noColor swatch', async ({ comfyPage }) => {
await panel.getColorSwatch('red').click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const selected = window.app!.canvas.selected_nodes
const node = Object.values(selected)[0]
return node?.color != null
})
)
.toBe(true)
await panel.getColorSwatch('noColor').click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const selected = window.app!.canvas.selected_nodes
const node = Object.values(selected)[0]
return node?.color
})
)
.toBeFalsy()
})
})
test.describe('Pinned state', () => {
test('should display pinned toggle', async () => {
await expect(panel.pinnedSwitch).toBeVisible()
})
test('should toggle pinned state', async ({ comfyPage }) => {
await panel.pinnedSwitch.click()
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
})
test('should unpin previously pinned node', async ({ comfyPage }) => {
const nodeLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
await panel.pinnedSwitch.click()
await expect(nodeLocator.getByTestId('node-pin-indicator')).toBeVisible()
await panel.pinnedSwitch.click()
await expect(
nodeLocator.getByTestId('node-pin-indicator')
).not.toBeVisible()
})
})
})

View File

@@ -1,32 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Open and close', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
test('should open via actionbar toggle button', async ({ comfyPage }) => {
await expect(panel.root).not.toBeVisible()
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
})
test('should close via panel close button', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
await panel.closeButton.click()
await expect(panel.root).not.toBeVisible()
})
test('should close via close button after opening', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
await panel.close()
await expect(panel.root).not.toBeVisible()
})
})

View File

@@ -0,0 +1,36 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Properties panel', () => {
test('opens and updates title based on selection', async ({ comfyPage }) => {
await comfyPage.actionbar.propertiesButton.click()
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
await propertiesPanel.searchBox.fill('seed')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(0)
await propertiesPanel.searchBox.fill('')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
).toHaveCount(2)
})
})

View File

@@ -1,41 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Search filtering', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
})
test('should filter nodes by search query', async () => {
await panel.searchWidgets('seed')
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
0
)
})
test('should restore all nodes when search is cleared', async () => {
await panel.searchWidgets('seed')
await panel.clearSearch()
await expect(panel.root.getByText('KSampler')).toHaveCount(1)
await expect(panel.root.getByText('CLIP Text Encode (Prompt)')).toHaveCount(
2
)
})
test('should show empty state for no matches', async () => {
await panel.searchWidgets('nonexistent_widget_xyz')
await expect(
panel.contentArea.getByText(/no .* match|no results|no items/i)
).toBeVisible()
})
})

View File

@@ -1,50 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Title editing', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
})
test('should show pencil icon for editable title', async () => {
await expect(panel.titleEditIcon).toBeVisible()
})
test('should enter edit mode on pencil click', async () => {
await panel.titleEditIcon.click()
await expect(panel.titleInput).toBeVisible()
})
test('should update node title on edit', async () => {
const newTitle = 'My Custom Sampler'
await panel.editTitle(newTitle)
await expect(panel.panelTitle).toContainText(newTitle)
})
test('should not show pencil icon for multi-selection', async ({
comfyPage
}) => {
await comfyPage.nodeOps.selectNodes([
'KSampler',
'CLIP Text Encode (Prompt)'
])
await expect(panel.titleEditIcon).not.toBeVisible()
})
test('should not show pencil icon when nothing is selected', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
await expect(panel.panelTitle).toContainText('Workflow Overview')
await expect(panel.titleEditIcon).not.toBeVisible()
})
})

View File

@@ -1,70 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Workflow Overview', () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
})
test('should show "Workflow Overview" title when nothing is selected', async () => {
await expect(panel.panelTitle).toContainText('Workflow Overview')
})
test('should show Parameters, Nodes, and Settings tabs', async () => {
await expect(panel.getTab('Parameters')).toBeVisible()
await expect(panel.getTab('Nodes')).toBeVisible()
await expect(panel.getTab('Settings')).toBeVisible()
})
test('should not show Info tab when nothing is selected', async () => {
await expect(panel.getTab('Info')).not.toBeVisible()
})
test('should switch to Nodes tab and list all workflow nodes', async ({
comfyPage
}) => {
await panel.switchToTab('Nodes')
const nodeCount = await comfyPage.nodeOps.getNodeCount()
expect(nodeCount).toBeGreaterThan(0)
await expect(panel.contentArea.locator('text=KSampler')).toBeVisible()
})
test('should filter nodes by search in Nodes tab', async () => {
await panel.switchToTab('Nodes')
await panel.searchWidgets('KSampler')
await expect(panel.contentArea.getByText('KSampler').first()).toBeVisible()
})
test('should switch to Settings tab and show global settings', async () => {
await panel.switchToTab('Settings')
await expect(panel.viewAllSettingsButton).toBeVisible()
})
test('should show "View all settings" button', async () => {
await panel.switchToTab('Settings')
await expect(panel.viewAllSettingsButton).toBeVisible()
})
test('should show Nodes section with toggles', async () => {
await panel.switchToTab('Settings')
await expect(
panel.contentArea.getByRole('button', { name: 'NODES' })
).toBeVisible()
})
test('should show Canvas section with grid settings', async () => {
await panel.switchToTab('Settings')
await expect(panel.contentArea.getByText('Canvas')).toBeVisible()
})
test('should show Connection Links section', async () => {
await panel.switchToTab('Settings')
await expect(panel.contentArea.getByText('Connection Links')).toBeVisible()
})
})

View File

@@ -1,118 +0,0 @@
import { expect } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { createMockJob } from '../../fixtures/helpers/AssetsHelper'
import { TestIds } from '../../fixtures/selectors'
const now = Date.now()
const MOCK_JOBS: JobEntry[] = [
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.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({
timeout: 5000
})
})
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({ timeout: 5000 })
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({
timeout: 5000
})
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({
timeout: 5000
})
await comfyPage.page
.getByRole('button', { name: 'Completed', exact: true })
.click()
await expect(
comfyPage.page.locator('[data-job-id="job-completed-1"]')
).toBeVisible({ timeout: 5000 })
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({
timeout: 5000
})
await toggle.click()
await expect(
comfyPage.page.locator('[data-job-id]').first()
).not.toBeVisible({ timeout: 5000 })
})
})

View File

@@ -1,20 +1,9 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
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')
}
import type { ComfyPage } from '../fixtures/ComfyPage'
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
const nodePos = await nodeRef.getPosition()
@@ -47,7 +36,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
() => window.app!.graph!._nodes.length
)
const deleteButton = comfyPage.page.getByTestId('delete-button')
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
@@ -62,12 +51,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const infoButton = comfyPage.page.getByTestId('info-button')
const infoButton = comfyPage.page.locator('[data-testid="info-button"]')
await expect(infoButton).toBeVisible()
await infoButton.click({ force: true })
await comfyPage.nextFrame()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
await expect(
comfyPage.page.locator('[data-testid="properties-panel"]')
).toBeVisible()
})
test('convert-to-subgraph button visible with multi-select', async ({
@@ -80,7 +71,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId('convert-to-subgraph-button')
comfyPage.page.locator('[data-testid="convert-to-subgraph-button"]')
).toBeVisible()
})
@@ -97,7 +88,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
() => window.app!.graph!._nodes.length
)
const deleteButton = comfyPage.page.getByTestId('delete-button')
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
@@ -107,152 +98,4 @@ 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()
})
})

View File

@@ -1,835 +1,30 @@
import { expect } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import type { GeneratedJobSeed } from '../../fixtures/helpers/AssetsHelper'
import {
createMockJob,
createMockJobs
} from '../../fixtures/helpers/AssetsHelper'
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const SAMPLE_JOBS: JobEntry[] = [
createMockJob({
id: 'job-alpha',
create_time: 1_000_000,
execution_start_time: 1_000_000,
execution_end_time: 1_010_000,
preview_output: {
filename: 'landscape.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-beta',
create_time: 2_000_000,
execution_start_time: 2_000_000,
execution_end_time: 2_003_000,
preview_output: {
filename: 'portrait.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-gamma',
create_time: 3_000_000,
execution_start_time: 3_000_000,
execution_end_time: 3_020_000,
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'
]
async function openSeededAssetsSidebar(
comfyPage: ComfyPage,
seed: Parameters<ComfyPage['assets']['seedAssets']>[0]
) {
await comfyPage.assets.seedAssets(seed)
await comfyPage.setup()
const tab = comfyPage.menu.assetsTab
await tab.open()
return tab
}
function makeGeneratedAssets(comfyPage: ComfyPage) {
const stacked: GeneratedJobSeed = {
jobId: 'job-gallery-stack',
outputs: [
{
filename: 'gallery-main.webp',
displayName: 'Gallery Main',
mediaType: 'images'
},
{
filename: 'gallery-alt.webp',
displayName: 'Gallery Alt',
mediaType: 'images'
},
{
filename: 'gallery-detail.webp',
displayName: 'Gallery Detail',
mediaType: 'images'
}
]
}
return {
sunrise: comfyPage.assets.generatedImage({
jobId: 'job-sunrise',
filename: 'sunrise.webp',
displayName: 'Sunrise'
}),
forest: comfyPage.assets.generatedImage({
jobId: 'job-forest',
filename: 'forest.webp',
displayName: 'Forest'
}),
stacked
}
}
// ==========================================================================
// 1. Empty states
// ==========================================================================
test.describe('Assets sidebar - empty states', () => {
test.describe('Assets sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
test('Shows empty-state copy for generated and imported tabs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.switchToImported()
await tab.importedTab.click()
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()
})
})
// ==========================================================================
// 11. Core journeys
// ==========================================================================
test.describe('Assets sidebar - core journeys', () => {
test.describe.configure({ timeout: 30_000 })
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Opens preview from list view and shows the media dialog', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openSeededAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
await tab.waitForAssets()
await tab.switchToListView()
await tab.openAssetPreview('Sunrise')
await expect(tab.previewDialog).toBeVisible()
await expect(tab.previewImage('sunrise.webp')).toBeVisible()
await tab.previewDialog.getByLabel('Close').click()
await expect(tab.previewDialog).not.toBeVisible()
})
test('Expands stacked outputs in list view', async ({ comfyPage }) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openSeededAssetsSidebar(comfyPage, {
generated: [generated.stacked]
})
await tab.waitForAssets()
await tab.switchToListView()
await expect(tab.asset('Gallery Alt')).not.toBeVisible()
await tab.toggleStack('Gallery Main')
await expect(tab.asset('Gallery Alt')).toBeVisible()
await expect(tab.asset('Gallery Detail')).toBeVisible()
})
test('Opens folder view for multi-output assets, copies the job ID, and returns back', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'], {
origin: comfyPage.url
})
const tab = await openSeededAssetsSidebar(comfyPage, {
generated: [generated.stacked]
})
await tab.waitForAssets()
await tab.openOutputFolder('Gallery Main')
await expect(tab.backButton).toBeVisible()
await expect(tab.copyJobIdButton).toBeVisible()
await expect(tab.asset('Gallery Main')).toBeVisible()
await expect(tab.asset('Gallery Alt')).toBeVisible()
await expect(tab.asset('Gallery Detail')).toBeVisible()
await tab.copyJobIdButton.click()
await expect(comfyPage.visibleToasts).toContainText('Copied')
await expect(comfyPage.visibleToasts).toContainText(
'Job ID copied to clipboard'
)
await tab.searchInput.click()
await comfyPage.clipboard.paste(tab.searchInput)
await expect(tab.searchInput).toHaveValue(generated.stacked.jobId)
await tab.backButton.click()
await expect(tab.asset('Gallery Main')).toBeVisible()
await expect(tab.asset('Gallery Alt')).not.toBeVisible()
await expect(tab.asset('Gallery Detail')).not.toBeVisible()
})
test('Downloads a selected asset from the selection footer', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openSeededAssetsSidebar(comfyPage, {
generated: [generated.sunrise, generated.forest]
})
await tab.waitForAssets()
await tab.selectAssets(['Sunrise'])
await expect(tab.selectionCountButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await tab.downloadSelectionButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('Sunrise')
await expect(tab.selectionCountButton).not.toBeVisible()
})
})

View File

@@ -1,244 +0,0 @@
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)
})
})

View File

@@ -1,126 +0,0 @@
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)
})
})

View File

@@ -1,77 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
/** Locate a workflow label in whatever panel is visible (browse or search). */
function findWorkflow(page: Page, name: string) {
return page
.getByTestId('workflows-sidebar')
.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 input is visible in workflows tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
await expect(
comfyPage.page.getByPlaceholder('Search Workflow...')
).toBeVisible()
})
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({
timeout: 5000
})
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({
timeout: 5000
})
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()
})
})

View File

@@ -7,10 +7,6 @@ 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'
@@ -23,6 +19,20 @@ 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')
@@ -594,19 +604,71 @@ 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 nodeIds = await comfyPage.page.evaluate(() =>
window
.app!.graph._nodes.filter((n) => !!n.isSubgraphNode?.())
.map((n) => String(n.id))
)
expect(nodeIds.length).toBeGreaterThan(0)
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph._nodes
const slotData: NodeSlotData[] = []
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}`)
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)
}
}
})
})

View File

@@ -1,148 +0,0 @@
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()
})
})
}
)

View File

@@ -1,154 +0,0 @@
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)
})
})

View File

@@ -1,512 +0,0 @@
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
)
})
})
})

View File

@@ -1,532 +0,0 @@
import { readFileSync } from 'fs'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Workflow Persistence', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Rapid tab switching does not desync workflow and graph state', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description: 'PR #9533 — desynced workflow/graph state during loading'
})
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.menu.topbar.saveWorkflow('rapid-A')
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.menu.topbar.saveWorkflow('rapid-B')
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountA).not.toBe(nodeCountB)
for (let i = 0; i < 3; i++) {
await tab.switchToWorkflow('rapid-A')
await tab.switchToWorkflow('rapid-B')
}
await comfyPage.workflow.waitForWorkflowIdle()
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
.toBe(nodeCountB)
await tab.switchToWorkflow('rapid-A')
await comfyPage.workflow.waitForWorkflowIdle()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
.toBe(nodeCountA)
})
test('Node outputs are preserved when switching workflow tabs', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #9380 — ChangeTracker.store() did not save nodeOutputs, losing preview images on tab switch'
})
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.menu.topbar.saveWorkflow('outputs-test')
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
expect(firstNode).toBeTruthy()
const nodeId = firstNode!.id
// Simulate node outputs as if execution completed
await comfyPage.page.evaluate((id) => {
const outputStore = window.app!.nodeOutputs
if (outputStore) {
outputStore[id] = {
images: [{ filename: 'test.png', subfolder: '', type: 'output' }]
}
}
}, String(nodeId))
// Trigger changeTracker to capture current state including outputs
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
await comfyPage.nextFrame()
const outputsBefore = await comfyPage.page.evaluate((id) => {
return window.app!.nodeOutputs?.[id]
}, String(nodeId))
expect(outputsBefore).toBeTruthy()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await tab.switchToWorkflow('outputs-test')
await comfyPage.nextFrame()
const outputsAfter = await comfyPage.page.evaluate((id) => {
return window.app!.nodeOutputs?.[id]
}, String(nodeId))
expect(outputsAfter).toBeTruthy()
expect(outputsAfter?.images).toBeDefined()
})
test('Loading a new workflow cleanly replaces the previous graph', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'Commit 44bb6f13 — canvas graph not reset before workflow load'
})
const defaultNodeCount = await comfyPage.nodeOps.getNodeCount()
expect(defaultNodeCount).toBeGreaterThan(1)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(1)
const nodes = await comfyPage.nodeOps.getNodes()
expect(nodes[0].type).toBe('KSampler')
})
test('Widget values on nodes are preserved across workflow tab switches', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description: 'PR #7648 — component widget state lost on graph change'
})
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.menu.topbar.saveWorkflow('widget-state-test')
// Read widget values via page.evaluate — these are internal LiteGraph
// state not exposed through DOM
const widgetValuesBefore = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph.nodes
const results: Record<string, unknown[]> = {}
for (const node of nodes) {
if (node.widgets && node.widgets.length > 0) {
results[node.id] = node.widgets.map((w) => ({
name: w.name,
value: w.value
}))
}
}
return results
})
expect(Object.keys(widgetValuesBefore).length).toBeGreaterThan(0)
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await tab.switchToWorkflow('widget-state-test')
await comfyPage.nextFrame()
const widgetValuesAfter = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph.nodes
const results: Record<string, unknown[]> = {}
for (const node of nodes) {
if (node.widgets && node.widgets.length > 0) {
results[node.id] = node.widgets.map((w) => ({
name: w.name,
value: w.value
}))
}
}
return results
})
expect(widgetValuesAfter).toEqual(widgetValuesBefore)
})
test('API format workflow with missing node types partially loads', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description: 'PR #9694 — loadApiJson early-returned on missing node types'
})
const fixturePath = comfyPage.assetPath(
'nodes/api_workflow_with_missing_nodes.json'
)
const apiWorkflow = JSON.parse(readFileSync(fixturePath, 'utf-8'))
await comfyPage.page.evaluate(async (workflow) => {
await window.app!.loadApiJson(workflow, 'test-api-workflow.json')
}, apiWorkflow)
await comfyPage.nextFrame()
// Known nodes (KSampler, EmptyLatentImage) should load; unknown node skipped
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBeGreaterThanOrEqual(2)
const nodeTypes = await comfyPage.page.evaluate(() => {
return window.app!.graph.nodes.map((n: { type: string }) => n.type)
})
expect(nodeTypes).toContain('KSampler')
expect(nodeTypes).toContain('EmptyLatentImage')
expect(nodeTypes).not.toContain('NonExistentCustomNode_XYZ_12345')
})
test('Canvas has auxclick handler to prevent middle-click paste', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #8259 — middle-click paste duplicates entire workflow on Linux'
})
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.canvas.click({
button: 'middle',
position: { x: 100, y: 100 }
})
await comfyPage.nextFrame()
const nodeCountAfter = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountAfter).toBe(initialNodeCount)
})
test('Exported workflow does not contain transient blob: URLs', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #8715 — transient image URLs leaked into workflow serialization'
})
const exportedWorkflow = await comfyPage.workflow.getExportedWorkflow()
for (const node of exportedWorkflow.nodes) {
if (node.widgets_values && Array.isArray(node.widgets_values)) {
for (const value of node.widgets_values) {
if (typeof value === 'string') {
expect(value).not.toMatch(/^blob:/)
expect(value).not.toMatch(/^https?:\/\/.*\/api\/view/)
}
}
}
}
})
test('Changing locale does not break workflow operations', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description: 'PR #8963 — template workflows not reloaded on locale change'
})
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.menu.topbar.saveWorkflow('locale-test')
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
await expect.poll(() => tab.getActiveWorkflowName()).toBe('locale-test')
})
test('Node links survive save/load/switch cycles', async ({ comfyPage }) => {
test.info().annotations.push({
type: 'regression',
description: 'PR #9533 — node links must survive serialization roundtrips'
})
const tab = comfyPage.menu.workflowsTab
await tab.open()
// Link count requires internal graph state — not exposed via DOM
const linkCountBefore = await comfyPage.page.evaluate(() => {
return window.app!.graph.links
? Object.keys(window.app!.graph.links).length
: 0
})
expect(linkCountBefore).toBeGreaterThan(0)
await comfyPage.menu.topbar.saveWorkflow('links-test')
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await tab.switchToWorkflow('links-test')
await comfyPage.workflow.waitForWorkflowIdle()
const linkCountAfter = await comfyPage.page.evaluate(() => {
return window.app!.graph.links
? Object.keys(window.app!.graph.links).length
: 0
})
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
}) => {
test.info().annotations.push({
type: 'regression',
description:
'Commits 91f197d9d + a1b7e57bc — splitter panel size drift on reload'
})
await comfyPage.page.evaluate(() => {
localStorage.setItem(
'Comfy.Splitter.MainSplitter',
JSON.stringify([30, 70])
)
})
await comfyPage.setup({ clearStorage: false })
await comfyPage.nextFrame()
const storedSizes = await comfyPage.page.evaluate(() => {
const raw = localStorage.getItem('Comfy.Splitter.MainSplitter')
return raw ? JSON.parse(raw) : null
})
expect(storedSizes).toBeTruthy()
expect(Array.isArray(storedSizes)).toBe(true)
for (const size of storedSizes as number[]) {
expect(typeof size).toBe('number')
expect(size).toBeGreaterThanOrEqual(0)
expect(size).not.toBeNaN()
}
const total = (storedSizes as number[]).reduce(
(a: number, b: number) => a + b,
0
)
expect(total).toBeGreaterThan(90)
expect(total).toBeLessThanOrEqual(101)
})
})

View File

@@ -363,7 +363,7 @@ Test your feature flags with different combinations:
### Example Test
```typescript
// Example from a colocated unit test
// In tests-ui/tests/api.featureFlags.test.ts
it('should handle preview metadata based on feature flag', () => {
// Mock server supports feature
api.serverFeatureFlags = { supports_preview_metadata: true }

View File

@@ -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 a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
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 a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
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 a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
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 a colocated store unit test
// Example from: tests-ui/tests/store/modelStore.test.ts
describe('getters', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -162,7 +162,7 @@ describe('getters', () => {
Mocking API and other dependencies:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
// 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 a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { nextTick } from 'vue'
describe('Subgraphs', () => {
@@ -253,7 +253,7 @@ describe('Subgraphs', () => {
Testing store integration with other parts of the application:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')

View File

@@ -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 a colocated composable unit test
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
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 a colocated LiteGraph unit test
// Example from: tests-ui/tests/litegraph.test.ts
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 a colocated workflow unit test
// Example from: tests-ui/tests/comfyWorkflow.test.ts
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 a colocated composable unit test
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
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 a colocated composable unit test
// Example from: tests-ui/tests/composables/useWorkflowAutoSave.test.ts
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 a colocated schema unit test
// Example from: tests-ui/tests/apiTypes.test.ts
import { describe, expect, it } from 'vitest'
import {
type ComfyNodeDef,

View File

@@ -230,6 +230,15 @@ export default defineConfig([
]
}
},
{
files: ['tests-ui/**/*'],
rules: {
'@typescript-eslint/consistent-type-imports': [
'error',
{ disallowTypeAnnotations: false }
]
}
},
{
files: ['**/*.spec.ts'],
ignores: ['browser_tests/tests/**/*.spec.ts'],

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.11",
"version": "1.43.9",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -45,7 +45,6 @@
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
@@ -58,7 +57,6 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/ingest-types": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
@@ -73,7 +71,6 @@
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
"@sparkjsdev/spark": "catalog:",
"@tanstack/vue-virtual": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-table": "catalog:",
@@ -141,7 +138,6 @@
"@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:",

View File

@@ -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}]");
@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}]");
/* 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}]");

View File

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 534 B

20
pnpm-lock.yaml generated
View File

@@ -102,9 +102,6 @@ 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
@@ -138,9 +135,6 @@ 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
@@ -416,9 +410,6 @@ importers:
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
'@comfyorg/ingest-types':
specifier: workspace:*
version: link:packages/ingest-types
'@comfyorg/registry-types':
specifier: workspace:*
version: link:packages/registry-types
@@ -461,9 +452,6 @@ 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)
@@ -660,9 +648,6 @@ 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
@@ -4286,9 +4271,6 @@ 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==}
@@ -13323,8 +13305,6 @@ 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':

View File

@@ -30,7 +30,6 @@ 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
@@ -47,7 +46,6 @@ 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

View File

@@ -1,4 +1,3 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -44,12 +43,12 @@ describe('downloadUtil', () => {
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
revokeObjectURLSpy.mockClear().mockImplementation(() => {})
// Create a mock anchor element
mockLink = fromPartial<HTMLAnchorElement>({
mockLink = {
href: '',
download: '',
click: vi.fn(),
style: { display: '' }
})
} as unknown as HTMLAnchorElement
// Spy on DOM methods
vi.spyOn(document, 'createElement').mockReturnValue(mockLink)
@@ -173,14 +172,12 @@ describe('downloadUtil', () => {
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
})
)
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
@@ -201,13 +198,11 @@ describe('downloadUtil', () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: false,
status: 404,
blob: vi.fn()
})
)
fetchMock.mockResolvedValue({
ok: false,
status: 404,
blob: vi.fn()
} as Partial<Response> as Response)
downloadFile(testUrl)
@@ -229,14 +224,12 @@ describe('downloadUtil', () => {
const headersMock = {
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
}
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
})
)
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
@@ -263,14 +256,12 @@ describe('downloadUtil', () => {
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
)
}
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
})
)
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
@@ -291,14 +282,12 @@ describe('downloadUtil', () => {
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
})
)
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl, 'my-fallback.png')
@@ -339,13 +328,11 @@ 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(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
})
)
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab(testUrl)
@@ -359,13 +346,11 @@ describe('downloadUtil', () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
})
)
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab('https://example.com/image.png')
@@ -379,10 +364,11 @@ 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(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({ ok: false, status: 404 })
)
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: false,
status: 404
} as unknown as Response)
await openFileInNewTab(testUrl)
@@ -395,13 +381,11 @@ describe('downloadUtil', () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
})
)
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab('https://example.com/image.png')

View File

@@ -33,91 +33,76 @@
{{ t('g.next') }}
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
</Button>
<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>
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<Button
v-else
size="lg"
class="w-full"
:class="activeSaveClasses"
:class="cn('w-24', disabledSaveClasses)"
data-testid="builder-save-as-button"
@click="saveAs()"
>
{{ t('builderToolbar.saveAs') }}
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
</Button>
</div>
</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>
</nav>
</div>
</template>
@@ -141,6 +126,8 @@ 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'

View File

@@ -51,10 +51,7 @@
}
"
>
<div
class="flex min-w-40 flex-col gap-2 p-2"
data-testid="more-menu-content"
>
<div class="flex min-w-40 flex-col gap-2 p-2">
<slot :close="hide" />
</div>
</Popover>

View File

@@ -37,13 +37,13 @@
</TreeRoot>
</ContextMenuTrigger>
<ContextMenuPortal v-if="showContextMenu && contextMenuNode?.data">
<ContextMenuPortal v-if="showContextMenu">
<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="handleToggleBookmark"
@select="handleAddToFavorites"
>
<i
:class="
@@ -59,14 +59,6 @@
: $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>
@@ -87,7 +79,6 @@ 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'
@@ -107,6 +98,7 @@ const emit = defineEmits<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
event: MouseEvent
]
addToFavorites: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
@@ -115,7 +107,6 @@ const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
provide(InjectKeyContextMenuNode, contextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const subgraphStore = useSubgraphStore()
const isCurrentNodeBookmarked = computed(() => {
const node = contextMenuNode.value
@@ -123,21 +114,9 @@ const isCurrentNodeBookmarked = computed(() => {
return nodeBookmarkStore.isBookmarked(node.data)
})
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)
function handleAddToFavorites() {
if (contextMenuNode.value) {
emit('addToFavorites', contextMenuNode.value)
}
}
</script>

View File

@@ -13,7 +13,7 @@ import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { delete: 'Delete' } } }
messages: { en: {} }
})
vi.mock('@/platform/settings/settingStore', () => ({
@@ -29,17 +29,6 @@ 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 />' }
}))
@@ -186,12 +175,8 @@ describe('TreeExplorerV2Node', () => {
expect(contextMenuNode.value).toEqual(nodeItem.value)
})
it('clears contextMenuNode when right-clicking a folder', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>({
key: 'stale',
type: 'node',
label: 'Stale'
} as RenderedTreeExplorerNode)
it('does not set contextMenuNode for folder items', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
const { wrapper } = mountComponent(
{ item: createMockItem('folder') },
@@ -209,59 +194,6 @@ 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({

View File

@@ -25,30 +25,25 @@
{{ item.value.label }}
</slot>
</span>
<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>
<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>
<!-- Folder -->
@@ -58,7 +53,6 @@
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@contextmenu="clearContextMenuNode"
>
<i
v-if="item.hasChildren"
@@ -102,7 +96,6 @@ 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'
@@ -114,9 +107,6 @@ 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>>
}>()
@@ -130,7 +120,6 @@ const emit = defineEmits<{
const contextMenuNode = inject(InjectKeyContextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const subgraphStore = useSubgraphStore()
const nodeDef = computed(() => item.value.data)
@@ -139,22 +128,12 @@ 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,
@@ -187,12 +166,6 @@ function handleContextMenu() {
}
}
function clearContextMenuNode() {
if (contextMenuNode) {
contextMenuNode.value = null
}
}
function handleMouseEnter(e: MouseEvent) {
if (item.value.type !== 'node') return
baseHandleMouseEnter(e)

View File

@@ -192,15 +192,3 @@ 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
}

View File

@@ -1,5 +1,3 @@
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'
@@ -11,6 +9,7 @@ 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>
@@ -29,7 +28,7 @@ function createNode(
}
function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
return fromPartial<TestWidget>({
return {
id,
node,
name: 'test_widget',
@@ -41,16 +40,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 fromPartial<LGraphCanvas>({
return {
graph,
low_quality: false,
read_only: false,
isNodeVisible: vi.fn(() => true)
})
} as unknown as LGraphCanvas
}
function drawFrame(canvas: LGraphCanvas) {

View File

@@ -7,7 +7,6 @@
<EditableText
:is-editing="showInput"
:model-value="editedTitle"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="onEdit"
/>
</div>

View File

@@ -1,14 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
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 = fromPartial<BaseDOMWidget<object | string>>({
const widget = {
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, {

View File

@@ -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 = fromPartial<{ _nodes: LGraphNode[] }>(graph)._nodes
const nodes = (graph as unknown as { _nodes: LGraphNode[] })._nodes
nodes.splice(nodes.indexOf(first), 1)
nodes.push(first)

View File

@@ -197,15 +197,4 @@ 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>

View File

@@ -1,7 +1,9 @@
<template>
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ t('load3d.viewer.cameraType') }}</label>
<div class="space-y-4">
<label>
{{ t('load3d.viewer.cameraType') }}
</label>
<Select
v-model="cameraType"
:options="cameras"
@@ -11,7 +13,7 @@
</Select>
</div>
<div v-if="showFOVButton" class="flex flex-col gap-2">
<div v-if="showFOVButton" class="space-y-4">
<label>{{ t('load3d.fov') }}</label>
<Slider
v-model="fov"

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-2">
<div class="space-y-4">
<label>{{ $t('load3d.lightIntensity') }}</label>
<Slider

View File

@@ -1,6 +1,6 @@
<template>
<div class="space-y-4">
<div class="flex flex-col gap-2">
<div>
<label>{{ $t('load3d.upDirection') }}</label>
<Select
v-model="upDirection"
@@ -10,7 +10,7 @@
/>
</div>
<div v-if="!hideMaterialMode" class="flex flex-col gap-2">
<div v-if="!hideMaterialMode">
<label>{{ $t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"

View File

@@ -1,10 +1,10 @@
<template>
<div class="space-y-4">
<div v-if="!hasBackgroundImage" class="flex flex-col gap-2">
<div v-if="!hasBackgroundImage">
<label>
{{ $t('load3d.backgroundColor') }}
</label>
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
<input v-model="backgroundColor" type="color" class="w-full" />
</div>
<div>

View File

@@ -20,7 +20,7 @@
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
<div class="min-h-0 flex-1">
<div class="min-h-0 flex-1 overflow-y-auto">
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItemEvent"

View File

@@ -1,3 +1,4 @@
/* 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'
@@ -5,27 +6,21 @@ 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 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 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 AssetsListItemStub = defineComponent({
name: 'AssetsListItem',
@@ -70,81 +65,71 @@ vi.mock('vue-i18n', () => {
}
})
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 = (
const createResultItem = (
filename: string,
mediaType: string = 'images'
): TestPreviewOutput => {
const url = `/api/view/${filename}`
return {
url,
isImage: mediaType === 'images',
isVideo: mediaType === 'video'
}
): 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?: TestPreviewOutput): TestTaskRef => ({
workflowId: 'workflow-1',
...(preview && { previewOutput: preview })
})
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
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const buildJob = (
overrides: Partial<TestJobListItem> = {}
): TestJobListItem => ({
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
taskRef: createTaskRef(createResultItem('job-1.png')),
...overrides
})
function renderJobAssetsList({
jobs = [],
displayedJobGroups,
attrs,
onViewItem
}: {
jobs?: TestJobListItem[]
displayedJobGroups?: TestJobGroup[]
attrs?: Record<string, string>
onViewItem?: (item: JobListItem) => void
} = {}) {
function renderJobAssetsList(
jobs: JobListItem[],
callbacks: {
onViewItem?: (item: JobListItem) => void
} = {}
) {
const displayedJobGroups: JobGroup[] = [
{
key: 'group-1',
label: 'Group 1',
items: jobs
}
]
const user = userEvent.setup()
const result = render(JobAssetsList, {
props: {
displayedJobGroups: (displayedJobGroups ?? [
{
key: 'group-1',
label: 'Group 1',
items: jobs
}
]) as JobGroup[],
...(onViewItem && { onViewItem })
displayedJobGroups,
...(callbacks.onViewItem && { onViewItem: callbacks.onViewItem })
},
attrs,
global: {
stubs: {
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub,
AssetsListItem: AssetsListItemStub
}
}
@@ -183,57 +168,10 @@ 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({ jobs: [job], onViewItem })
const { user } = renderJobAssetsList([job], { onViewItem })
await user.click(screen.getByTestId('preview-trigger'))
@@ -243,7 +181,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({ jobs: [job], onViewItem })
const { container, user } = renderJobAssetsList([job], { onViewItem })
const stubRoot = container.querySelector('.assets-list-item-stub')!
await user.dblClick(stubRoot)
@@ -254,10 +192,10 @@ describe('JobAssetsList', () => {
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const { container, user } = renderJobAssetsList([job], { onViewItem })
const stubRoot = container.querySelector('.assets-list-item-stub')!
expect(stubRoot.getAttribute('data-preview-url')).toBe(
@@ -273,10 +211,10 @@ describe('JobAssetsList', () => {
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const { container, user } = renderJobAssetsList([job], { onViewItem })
const icon = container.querySelector('.assets-list-item-stub i')!
await user.click(icon)
@@ -287,10 +225,10 @@ describe('JobAssetsList', () => {
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
taskRef: createTaskRef(createResultItem('job-1.png'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const { container, user } = renderJobAssetsList([job], { onViewItem })
const stubRoot = container.querySelector('.assets-list-item-stub')!
await user.dblClick(stubRoot)
@@ -304,7 +242,7 @@ describe('JobAssetsList', () => {
taskRef: createTaskRef()
})
const onViewItem = vi.fn()
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
const { container } = renderJobAssetsList([job], { onViewItem })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
@@ -318,7 +256,7 @@ describe('JobAssetsList', () => {
it('shows and hides the job details popover with hover delays', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList({ jobs: [job] })
const { container } = renderJobAssetsList([job])
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -348,7 +286,7 @@ describe('JobAssetsList', () => {
it('keeps the job details popover open while hovering the popover', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList({ jobs: [job] })
const { container } = renderJobAssetsList([job])
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -381,7 +319,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({ jobs: [job] })
const { container } = renderJobAssetsList([job])
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -406,7 +344,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({ jobs: [job] })
const { container } = renderJobAssetsList([job])
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -432,9 +370,7 @@ describe('JobAssetsList', () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const { container } = renderJobAssetsList({
jobs: [firstJob, secondJob]
})
const { container } = renderJobAssetsList([firstJob, secondJob])
const firstRow = container.querySelector('[data-job-id="job-1"]')!
const secondRow = container.querySelector('[data-job-id="job-2"]')!
@@ -462,9 +398,7 @@ describe('JobAssetsList', () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const { container } = renderJobAssetsList({
jobs: [firstJob, secondJob]
})
const { container } = renderJobAssetsList([firstJob, secondJob])
const firstRow = container.querySelector('[data-job-id="job-1"]')!
const secondRow = container.querySelector('[data-job-id="job-2"]')!
@@ -495,7 +429,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({ jobs: [job] })
const { container, rerender } = renderJobAssetsList([job])
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!

View File

@@ -1,92 +1,79 @@
<template>
<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)"
<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
>
{{ 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
<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)"
>
<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>
<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>
</div>
</div>
@@ -110,11 +97,8 @@
</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 { computed, nextTick, ref } from 'vue'
import { nextTick, ref } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
@@ -126,17 +110,6 @@ 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<{
@@ -147,43 +120,9 @@ 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,
@@ -196,37 +135,6 @@ 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

View File

@@ -1,82 +0,0 @@
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]
}
])
})
})

View File

@@ -1,37 +0,0 @@
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
}

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