Compare commits

..

11 Commits

Author SHA1 Message Date
pythongosssss
cac65ebb96 Merge remote-tracking branch 'origin/main' into pysssss/appmode/execution-tests
# Conflicts:
#	browser_tests/fixtures/helpers/AppModeHelper.ts
#	browser_tests/tests/actionbar.spec.ts
2026-04-08 12:37:47 -07:00
pythongosssss
20a50817af Merge remote-tracking branch 'origin/main' into pysssss/appmode/execution-tests
# Conflicts:
#	browser_tests/fixtures/helpers/AppModeHelper.ts
2026-04-08 10:45:54 -07:00
pythongosssss
d986dcab3a prevent hitting server 2026-04-08 05:50:03 -07:00
pythongosssss
ab4a00d6dc add new progress bar tests
tidy tests, remove unused/unnecessary code
2026-04-08 05:38:47 -07:00
pythongosssss
4492e3f2d6 replace manual page evaluate 2026-04-08 02:53:50 -07:00
pythongosssss
70b8e660bf Merge remote-tracking branch 'origin/main' into pysssss/appmode/execution-tests
# Conflicts:
#	browser_tests/fixtures/helpers/AppModeHelper.ts
#	browser_tests/fixtures/selectors.ts
2026-04-08 02:48:43 -07:00
pythongosssss
fb8684e218 fix imports 2026-04-08 02:42:10 -07:00
pythongosssss
d8ac560229 fix bug with auto selection incorrectly over-following
improve tests
2026-04-07 13:28:29 -07:00
pythongosssss
e298998732 rabbit fixes 2026-04-07 13:02:44 -07:00
pythongosssss
ff3b43ac39 add test for scrolling outputs
refactor execution helper to use assets helper for mock jobs
2026-04-07 12:38:04 -07:00
pythongosssss
fd4782f37d add app mode execution tests
add execution helper
2026-04-07 12:38:04 -07:00
238 changed files with 9977 additions and 10966 deletions

View File

@@ -142,22 +142,10 @@ jobs:
fi
echo "✅ Branch '$BRANCH' exists"
- name: Ensure packageManager field exists
run: |
if ! grep -q '"packageManager"' package.json; then
# Old branches (e.g. core/1.42) predate the packageManager field.
# Inject it so pnpm/action-setup can resolve the version.
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
pkg.packageManager = 'pnpm@10.33.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "Injected packageManager into package.json for legacy branch"
fi
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -64,7 +64,6 @@
]
}
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
@@ -105,7 +104,8 @@
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error"
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},
"overrides": [
{

View File

@@ -179,12 +179,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
24. Do not use function expressions if it's possible to use function declarations instead
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
## Design Standards
Before implementing any user-facing feature, consult the [Comfy Design Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards) Figma file. Use the Figma MCP to fetch it live — the file is the single source of truth and may be updated by designers at any time.
See `docs/guidance/design-standards.md` for Figma file keys, section node IDs, and component references.
## Testing Guidelines
See @docs/testing/\*.md for detailed patterns.
@@ -232,7 +226,6 @@ See @docs/testing/\*.md for detailed patterns.
- shadcn/vue: <https://www.shadcn-vue.com/>
- Reka UI: <https://reka-ui.com/>
- PrimeVue: <https://primevue.org>
- Comfy Design Standards: <https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards>
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>

View File

@@ -62,37 +62,6 @@ python main.py --port 8188 --cpu
- Run `pnpm dev:electron` to start the dev server with electron API mocked
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)
#### Testing with Cloud & Staging Environments
Some features — particularly **partner/API nodes** (e.g. BFL, OpenAI, Stability AI) — require a cloud backend for authentication and billing. Running these against a local ComfyUI instance will result in permission errors or logged-out states. There are two ways to connect to a cloud/staging backend:
**Option 1: Frontend — `pnpm dev:cloud`**
The simplest approach. This proxies all API requests to the test cloud environment:
```bash
pnpm dev:cloud
```
This sets `DEV_SERVER_COMFYUI_URL` to `https://testcloud.comfy.org/` automatically. You can also set this variable manually in your `.env` file to target a different environment:
```bash
# .env
DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/
```
Any `*.comfy.org` URL automatically enables cloud mode, which includes the GCS media proxy needed for viewing generated images and videos. See [.env_example](.env_example) for all available cloud URLs.
**Option 2: Backend — `--comfy-api-base`**
Alternatively, launch the ComfyUI backend pointed at the staging API:
```bash
python main.py --comfy-api-base https://stagingapi.comfy.org --verbose
```
Then run `pnpm dev` as usual. This keeps the frontend in local mode but routes backend API calls through staging.
#### Access dev server on touch devices
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.

View File

@@ -1,15 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const features = computed(() => [
{ icon: '📚', label: t('academy.tutorials', locale) },
{ icon: '🎥', label: t('academy.videos', locale) },
{ icon: '🛠️', label: t('academy.projects', locale) }
])
const features = [
{ icon: '📚', label: 'Guided Tutorials' },
{ icon: '🎥', label: 'Video Courses' },
{ icon: '🛠️', label: 'Hands-on Projects' }
]
</script>
<template>
@@ -19,15 +13,14 @@ const features = computed(() => [
<span
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
>
{{ t('academy.badge', locale) }}
COMFY ACADEMY
</span>
<h2 class="mt-6 text-3xl font-bold text-white">
{{ t('academy.heading', locale) }}
</h2>
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
<p class="mt-4 text-smoke-700">
{{ t('academy.body', locale) }}
Learn to build professional AI workflows with guided tutorials, video
courses, and hands-on projects.
</p>
<!-- Feature bullets -->
@@ -47,7 +40,7 @@ const features = computed(() => [
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"
>
{{ t('academy.cta', locale) }}
EXPLORE ACADEMY
</a>
</div>
</section>

View File

@@ -1,43 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards = computed(() => [
const cards = [
{
icon: '🖥️',
title: t('cta.desktop.title', locale),
description: t('cta.desktop.desc', locale),
cta: t('cta.desktop.cta', locale),
title: 'Comfy Desktop',
description: 'Full power on your local machine. Free and open source.',
cta: 'DOWNLOAD',
href: '/download',
outlined: false
},
{
icon: '☁️',
title: t('cta.cloud.title', locale),
description: t('cta.cloud.desc', locale),
cta: t('cta.cloud.cta', locale),
title: 'Comfy Cloud',
description: 'Run workflows in the cloud. No GPU required.',
cta: 'TRY CLOUD',
href: 'https://app.comfy.org',
outlined: false
},
{
icon: '⚡',
title: t('cta.api.title', locale),
description: t('cta.api.desc', locale),
cta: t('cta.api.cta', locale),
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">
{{ t('cta.heading', locale) }}
Choose Your Way to Comfy
</h2>
<!-- CTA cards -->

View File

@@ -1,37 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const steps = computed(() => [
const steps = [
{
number: '1',
title: t('getStarted.step1.title', locale),
description: t('getStarted.step1.desc', locale)
title: 'Download & Sign Up',
description: 'Get Comfy Desktop for free or create a Cloud account'
},
{
number: '2',
title: t('getStarted.step2.title', locale),
description: t('getStarted.step2.desc', locale)
title: 'Load a Workflow',
description:
'Choose from thousands of community workflows or build your own'
},
{
number: '3',
title: t('getStarted.step3.title', locale),
description: t('getStarted.step3.desc', locale)
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">
{{ t('getStarted.heading', locale) }}
</h2>
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
<p class="mt-4 text-smoke-700">
{{ t('getStarted.subheading', locale) }}
From download to your first AI-generated output in three simple steps
</p>
<!-- Steps -->
@@ -62,7 +55,7 @@ const steps = computed(() => [
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"
>
{{ t('getStarted.cta', locale) }}
DOWNLOAD COMFY
</a>
</div>
</section>

View File

@@ -1,23 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctaButtons = computed(() => [
const ctaButtons = [
{
label: t('hero.cta.getStarted', locale),
label: 'GET STARTED',
href: 'https://app.comfy.org',
variant: 'solid' as const
},
{
label: t('hero.cta.learnMore', locale),
label: 'LEARN MORE',
href: '/about',
variant: 'outline' as const
}
])
]
</script>
<template>
@@ -46,11 +39,12 @@ const ctaButtons = computed(() => [
<h1
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
>
{{ t('hero.headline', locale) }}
Professional Control of Visual AI
</h1>
<p class="mt-6 max-w-lg text-lg text-smoke-700">
{{ t('hero.subheadline', locale) }}
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">

View File

@@ -1,10 +1,3 @@
<script setup lang="ts">
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="bg-black py-24">
<div class="mx-auto max-w-4xl px-6 text-center">
@@ -14,11 +7,13 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</span>
<h2 class="text-4xl font-bold text-white md:text-5xl">
{{ t('manifesto.heading', locale) }}
Method, Not Magic
</h2>
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
{{ t('manifesto.body', locale) }}
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 -->

View File

@@ -1,16 +1,6 @@
<!-- TODO: Replace with actual workflow demo content -->
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const features = computed(() => [
t('showcase.nodeEditor', locale),
t('showcase.realTimePreview', locale),
t('showcase.versionControl', locale)
])
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
</script>
<template>
@@ -18,11 +8,9 @@ const features = computed(() => [
<div class="mx-auto max-w-7xl px-6">
<!-- Section header -->
<div class="text-center">
<h2 class="text-3xl font-bold text-white">
{{ t('showcase.heading', locale) }}
</h2>
<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">
{{ t('showcase.subheading', locale) }}
Watch how professionals build AI workflows with unprecedented control
</p>
</div>
@@ -40,9 +28,7 @@ const features = computed(() => [
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">
{{ t('showcase.placeholder', locale) }}
</p>
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
</div>
</div>

View File

@@ -1,73 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { localePath, t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const columns = computed(() => [
const columns = [
{
title: t('footer.product', locale),
title: 'Product',
links: [
{
label: t('footer.comfyDesktop', locale),
href: localePath('/download', locale)
},
{ label: t('footer.comfyCloud', locale), href: 'https://app.comfy.org' },
{ label: t('footer.comfyHub', locale), href: 'https://hub.comfy.org' },
{
label: t('footer.pricing', locale),
href: localePath('/pricing', locale)
}
{ label: 'Comfy Desktop', href: '/download' },
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
{ label: 'Pricing', href: '/pricing' }
]
},
{
title: t('footer.resources', locale),
title: 'Resources',
links: [
{
label: t('footer.documentation', locale),
href: 'https://docs.comfy.org'
},
{ label: t('footer.blog', locale), href: 'https://blog.comfy.org' },
{
label: t('footer.gallery', locale),
href: localePath('/gallery', locale)
},
{
label: t('footer.github', locale),
href: 'https://github.com/comfyanonymous/ComfyUI'
}
{ label: 'Documentation', href: 'https://docs.comfy.org' },
{ label: 'Blog', href: 'https://blog.comfy.org' },
{ label: 'Gallery', href: '/gallery' },
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
]
},
{
title: t('footer.company', locale),
title: 'Company',
links: [
{ label: t('footer.about', locale), href: localePath('/about', locale) },
{
label: t('footer.careers', locale),
href: localePath('/careers', locale)
},
{
label: t('footer.enterprise', locale),
href: localePath('/enterprise', locale)
}
{ label: 'About', href: '/about' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' }
]
},
{
title: t('footer.legal', locale),
title: 'Legal',
links: [
{
label: t('footer.terms', locale),
href: localePath('/terms-of-service', locale)
},
{
label: t('footer.privacy', locale),
href: localePath('/privacy-policy', locale)
}
{ label: 'Terms of Service', href: '/terms-of-service' },
{ label: 'Privacy Policy', href: '/privacy-policy' }
]
}
])
]
const socials = [
{
@@ -110,16 +76,11 @@ const socials = [
>
<!-- Brand -->
<div class="lg:col-span-1">
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<a
:href="localePath('/', locale)"
class="text-2xl font-bold text-brand-yellow italic"
>
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
Comfy
</a>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
<p class="mt-4 text-sm text-smoke-700">
{{ t('footer.tagline', locale) }}
Professional control of visual AI.
</p>
</div>
@@ -152,8 +113,7 @@ const socials = [
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
>
<p class="text-sm text-smoke-700">
&copy; {{ new Date().getFullYear() }}
{{ t('footer.copyright', locale) }}
&copy; {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
</p>
<!-- Social icons -->

View File

@@ -1,23 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../i18n/translations'
import { localePath, t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
import { onMounted, onUnmounted, ref } from 'vue'
const mobileMenuOpen = ref(false)
const currentPath = ref('')
const navLinks = computed(() => [
{
label: t('nav.enterprise', locale),
href: localePath('/enterprise', locale)
},
{ label: t('nav.gallery', locale), href: localePath('/gallery', locale) },
{ label: t('nav.about', locale), href: localePath('/about', locale) },
{ label: t('nav.careers', locale), href: localePath('/careers', locale) }
])
const navLinks = [
{ label: 'ENTERPRISE', href: '/enterprise' },
{ label: 'GALLERY', href: '/gallery' },
{ label: 'ABOUT', href: '/about' },
{ label: 'CAREERS', href: '/careers' }
]
const ctaLinks = [
{
@@ -57,19 +49,14 @@ onUnmounted(() => {
<template>
<nav
class="fixed inset-x-0 top-0 z-50 bg-black/80 backdrop-blur-md"
:aria-label="t('nav.ariaLabel', locale)"
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
aria-label="Main navigation"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<!-- Logo -->
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<a
:href="localePath('/', locale)"
class="text-2xl font-bold text-brand-yellow italic"
>
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
Comfy
</a>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
<!-- Desktop nav links -->
<div class="hidden items-center gap-8 md:flex">
@@ -90,8 +77,8 @@ onUnmounted(() => {
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
"
class="rounded-full px-5 py-2 text-sm font-semibold"
>
@@ -103,7 +90,7 @@ onUnmounted(() => {
<!-- Mobile hamburger -->
<button
class="flex flex-col gap-1.5 md:hidden"
:aria-label="t('nav.toggleMenu', locale)"
aria-label="Toggle menu"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
@@ -148,8 +135,8 @@ onUnmounted(() => {
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
"
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
>

View File

@@ -1,10 +1,4 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logos = [
'Harman',
'Tencent',
@@ -20,11 +14,11 @@ const logos = [
'EA'
]
const metrics = computed(() => [
{ value: '60K+', label: t('social.customNodes', locale) },
{ value: '106K+', label: t('social.githubStars', locale) },
{ value: '500K+', label: t('social.communityMembers', locale) }
])
const metrics = [
{ value: '60K+', label: 'Custom Nodes' },
{ value: '106K+', label: 'GitHub Stars' },
{ value: '500K+', label: 'Community Members' }
]
</script>
<template>
@@ -34,7 +28,7 @@ const metrics = computed(() => [
<p
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
>
{{ t('social.heading', locale) }}
Trusted by Industry Leaders
</p>
<!-- Logo row -->

View File

@@ -1,28 +1,9 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const activeFilter = ref('All')
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const industryKeys = [
'All',
'VFX',
'Gaming',
'Advertising',
'Photography'
] as const
const industryLabels = computed(() => ({
All: t('testimonials.all', locale),
VFX: t('testimonials.vfx', locale),
Gaming: t('testimonials.gaming', locale),
Advertising: t('testimonials.advertising', locale),
Photography: t('testimonials.photography', locale)
}))
const activeFilter = ref<(typeof industryKeys)[number]>('All')
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
const testimonials = [
{
@@ -31,7 +12,7 @@ const testimonials = [
name: 'Sarah Chen',
title: 'Lead Technical Artist',
company: 'Studio Alpha',
industry: 'VFX' as const
industry: 'VFX'
},
{
quote:
@@ -39,7 +20,7 @@ const testimonials = [
name: 'Marcus Rivera',
title: 'Creative Director',
company: 'PixelForge',
industry: 'Gaming' as const
industry: 'Gaming'
},
{
quote:
@@ -47,7 +28,7 @@ const testimonials = [
name: 'Yuki Tanaka',
title: 'Head of AI',
company: 'CreativeX',
industry: 'Advertising' as const
industry: 'Advertising'
}
]
@@ -61,13 +42,13 @@ const filteredTestimonials = computed(() => {
<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">
{{ t('testimonials.heading', locale) }}
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 industryKeys"
v-for="industry in industries"
:key="industry"
type="button"
:aria-pressed="activeFilter === industry"
@@ -79,7 +60,7 @@ const filteredTestimonials = computed(() => {
"
@click="activeFilter = industry"
>
{{ industryLabels[industry] }}
{{ industry }}
</button>
</div>
@@ -104,7 +85,7 @@ const filteredTestimonials = computed(() => {
<span
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
>
{{ industryLabels[testimonial.industry] ?? testimonial.industry }}
{{ testimonial.industry }}
</span>
</article>
</div>

View File

@@ -1,18 +1,14 @@
<!-- TODO: Wire category content swap when final assets arrive -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
import { ref } from 'vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const categories = computed(() => [
t('useCase.vfx', locale),
t('useCase.agencies', locale),
t('useCase.gaming', locale),
t('useCase.ecommerce', locale),
t('useCase.community', locale)
])
const categories = [
'VFX & Animation',
'Creative Agencies',
'Gaming',
'eCommerce & Fashion',
'Community & Hobbyists'
]
const activeCategory = ref(0)
</script>
@@ -31,7 +27,7 @@ const activeCategory = ref(0)
<!-- Center content -->
<div class="flex flex-col items-center text-center lg:flex-[2]">
<h2 class="text-3xl font-bold text-white">
{{ t('useCase.heading', locale) }}
Built for Every Creative Industry
</h2>
<nav
@@ -56,14 +52,15 @@ const activeCategory = ref(0)
</nav>
<p class="mt-10 max-w-lg text-smoke-700">
{{ t('useCase.body', locale) }}
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"
>
{{ t('useCase.cta', locale) }}
EXPLORE WORKFLOWS
</a>
</div>

View File

@@ -1,37 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const pillars = computed(() => [
const pillars = [
{
icon: '⚡',
title: t('pillars.buildTitle', locale),
description: t('pillars.buildDesc', locale)
title: 'Build',
description:
'Design complex AI workflows visually with our node-based editor'
},
{
icon: '🎨',
title: t('pillars.customizeTitle', locale),
description: t('pillars.customizeDesc', locale)
title: 'Customize',
description: 'Fine-tune every parameter across any model architecture'
},
{
icon: '🔧',
title: t('pillars.refineTitle', locale),
description: t('pillars.refineDesc', locale)
title: 'Refine',
description:
'Iterate on outputs with precision controls and real-time preview'
},
{
icon: '⚙️',
title: t('pillars.automateTitle', locale),
description: t('pillars.automateDesc', locale)
title: 'Automate',
description:
'Scale your workflows with batch processing and API integration'
},
{
icon: '🚀',
title: t('pillars.runTitle', locale),
description: t('pillars.runDesc', locale)
title: 'Run',
description: 'Deploy locally or in the cloud with identical results'
}
])
]
</script>
<template>
@@ -39,10 +36,10 @@ const pillars = computed(() => [
<div class="mx-auto max-w-7xl">
<header class="mb-16 text-center">
<h2 class="text-3xl font-bold text-white md:text-4xl">
{{ t('pillars.heading', locale) }}
The Building Blocks of AI Production
</h2>
<p class="mt-4 text-smoke-700">
{{ t('pillars.subheading', locale) }}
Five powerful capabilities that give you complete control
</p>
</header>

View File

@@ -1,253 +0,0 @@
type Locale = 'en' | 'zh-CN'
const translations = {
// HeroSection
'hero.headline': {
en: 'Professional Control of Visual AI',
'zh-CN': '视觉 AI 的专业控制'
},
'hero.subheadline': {
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
'zh-CN':
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
},
'hero.cta.getStarted': { en: 'GET STARTED', 'zh-CN': '立即开始' },
'hero.cta.learnMore': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
// SocialProofBar
'social.heading': {
en: 'Trusted by Industry Leaders',
'zh-CN': '受到行业领导者的信赖'
},
'social.customNodes': { en: 'Custom Nodes', 'zh-CN': '自定义节点' },
'social.githubStars': { en: 'GitHub Stars', 'zh-CN': 'GitHub 星标' },
'social.communityMembers': {
en: 'Community Members',
'zh-CN': '社区成员'
},
// ProductShowcase
'showcase.heading': { en: 'See Comfy in Action', 'zh-CN': '观看 Comfy 实战' },
'showcase.subheading': {
en: 'Watch how professionals build AI workflows with unprecedented control',
'zh-CN': '观看专业人士如何以前所未有的控制力构建 AI 工作流'
},
'showcase.placeholder': {
en: 'Workflow Demo Coming Soon',
'zh-CN': '工作流演示即将推出'
},
'showcase.nodeEditor': { en: 'Node-Based Editor', 'zh-CN': '节点编辑器' },
'showcase.realTimePreview': {
en: 'Real-Time Preview',
'zh-CN': '实时预览'
},
'showcase.versionControl': {
en: 'Version Control',
'zh-CN': '版本控制'
},
// ValuePillars
'pillars.heading': {
en: 'The Building Blocks of AI Production',
'zh-CN': 'AI 制作的基本要素'
},
'pillars.subheading': {
en: 'Five powerful capabilities that give you complete control',
'zh-CN': '五大强大功能,让您完全掌控'
},
'pillars.buildTitle': { en: 'Build', 'zh-CN': '构建' },
'pillars.buildDesc': {
en: 'Design complex AI workflows visually with our node-based editor',
'zh-CN': '使用节点编辑器直观地设计复杂的 AI 工作流'
},
'pillars.customizeTitle': { en: 'Customize', 'zh-CN': '自定义' },
'pillars.customizeDesc': {
en: 'Fine-tune every parameter across any model architecture',
'zh-CN': '在任何模型架构中微调每个参数'
},
'pillars.refineTitle': { en: 'Refine', 'zh-CN': '优化' },
'pillars.refineDesc': {
en: 'Iterate on outputs with precision controls and real-time preview',
'zh-CN': '通过精确控制和实时预览迭代输出'
},
'pillars.automateTitle': { en: 'Automate', 'zh-CN': '自动化' },
'pillars.automateDesc': {
en: 'Scale your workflows with batch processing and API integration',
'zh-CN': '通过批处理和 API 集成扩展工作流'
},
'pillars.runTitle': { en: 'Run', 'zh-CN': '运行' },
'pillars.runDesc': {
en: 'Deploy locally or in the cloud with identical results',
'zh-CN': '在本地或云端部署,获得相同的结果'
},
// UseCaseSection
'useCase.heading': {
en: 'Built for Every Creative Industry',
'zh-CN': '为每个创意行业而生'
},
'useCase.vfx': { en: 'VFX & Animation', 'zh-CN': '视觉特效与动画' },
'useCase.agencies': { en: 'Creative Agencies', 'zh-CN': '创意机构' },
'useCase.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
'useCase.ecommerce': {
en: 'eCommerce & Fashion',
'zh-CN': '电商与时尚'
},
'useCase.community': {
en: 'Community & Hobbyists',
'zh-CN': '社区与爱好者'
},
'useCase.body': {
en: 'Powered by 60,000+ nodes, thousands of workflows, and a community that builds faster than any one company could.',
'zh-CN':
'由 60,000+ 节点、数千个工作流和一个比任何公司都更快构建的社区驱动。'
},
'useCase.cta': { en: 'EXPLORE WORKFLOWS', 'zh-CN': '探索工作流' },
// CaseStudySpotlight
'caseStudy.heading': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'caseStudy.subheading': {
en: 'See how leading studios use Comfy in production',
'zh-CN': '了解领先工作室如何在生产中使用 Comfy'
},
'caseStudy.readMore': { en: 'READ CASE STUDY', 'zh-CN': '阅读案例' },
// TestimonialsSection
'testimonials.heading': {
en: 'What Professionals Say',
'zh-CN': '专业人士的评价'
},
'testimonials.all': { en: 'All', 'zh-CN': '全部' },
'testimonials.vfx': { en: 'VFX', 'zh-CN': '特效' },
'testimonials.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
'testimonials.advertising': { en: 'Advertising', 'zh-CN': '广告' },
'testimonials.photography': { en: 'Photography', 'zh-CN': '摄影' },
// GetStartedSection
'getStarted.heading': {
en: 'Get Started in Minutes',
'zh-CN': '几分钟即可开始'
},
'getStarted.subheading': {
en: 'From download to your first AI-generated output in three simple steps',
'zh-CN': '从下载到首次 AI 生成输出,只需三个简单步骤'
},
'getStarted.step1.title': {
en: 'Download & Sign Up',
'zh-CN': '下载与注册'
},
'getStarted.step1.desc': {
en: 'Get Comfy Desktop for free or create a Cloud account',
'zh-CN': '免费获取 Comfy Desktop 或创建云端账号'
},
'getStarted.step2.title': {
en: 'Load a Workflow',
'zh-CN': '加载工作流'
},
'getStarted.step2.desc': {
en: 'Choose from thousands of community workflows or build your own',
'zh-CN': '从数千个社区工作流中选择,或自行构建'
},
'getStarted.step3.title': { en: 'Generate', 'zh-CN': '生成' },
'getStarted.step3.desc': {
en: 'Hit run and watch your AI workflow come to life',
'zh-CN': '点击运行,观看 AI 工作流生动呈现'
},
'getStarted.cta': { en: 'DOWNLOAD COMFY', 'zh-CN': '下载 COMFY' },
// CTASection
'cta.heading': {
en: 'Choose Your Way to Comfy',
'zh-CN': '选择您的 Comfy 方式'
},
'cta.desktop.title': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
'cta.desktop.desc': {
en: 'Full power on your local machine. Free and open source.',
'zh-CN': '在本地机器上释放全部性能。免费开源。'
},
'cta.desktop.cta': { en: 'DOWNLOAD', 'zh-CN': '下载' },
'cta.cloud.title': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
'cta.cloud.desc': {
en: 'Run workflows in the cloud. No GPU required.',
'zh-CN': '在云端运行工作流,无需 GPU。'
},
'cta.cloud.cta': { en: 'TRY CLOUD', 'zh-CN': '试用云端' },
'cta.api.title': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
'cta.api.desc': {
en: 'Integrate AI generation into your applications.',
'zh-CN': '将 AI 生成功能集成到您的应用程序中。'
},
'cta.api.cta': { en: 'VIEW DOCS', 'zh-CN': '查看文档' },
// ManifestoSection
'manifesto.heading': { en: 'Method, Not Magic', 'zh-CN': '方法,而非魔法' },
'manifesto.body': {
en: '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.',
'zh-CN':
'我们相信应赋予创作者对 AI 的真正控制权。没有黑箱,没有魔法按钮,而是对创作过程每一步的透明、可复现、逐节点控制。'
},
// AcademySection
'academy.badge': { en: 'COMFY ACADEMY', 'zh-CN': 'COMFY 学院' },
'academy.heading': {
en: 'Master AI Workflows',
'zh-CN': '掌握 AI 工作流'
},
'academy.body': {
en: 'Learn to build professional AI workflows with guided tutorials, video courses, and hands-on projects.',
'zh-CN': '通过指导教程、视频课程和实践项目,学习构建专业的 AI 工作流。'
},
'academy.tutorials': { en: 'Guided Tutorials', 'zh-CN': '指导教程' },
'academy.videos': { en: 'Video Courses', 'zh-CN': '视频课程' },
'academy.projects': { en: 'Hands-on Projects', 'zh-CN': '实践项目' },
'academy.cta': { en: 'EXPLORE ACADEMY', 'zh-CN': '探索学院' },
// SiteNav
'nav.ariaLabel': { en: 'Main navigation', 'zh-CN': '主导航' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.enterprise': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'nav.gallery': { en: 'GALLERY', 'zh-CN': '画廊' },
'nav.about': { en: 'ABOUT', 'zh-CN': '关于' },
'nav.careers': { en: 'CAREERS', 'zh-CN': '招聘' },
'nav.cloud': { en: 'COMFY CLOUD', 'zh-CN': 'COMFY 云端' },
'nav.hub': { en: 'COMFY HUB', 'zh-CN': 'COMFY HUB' },
// SiteFooter
'footer.tagline': {
en: 'Professional control of visual AI.',
'zh-CN': '视觉 AI 的专业控制。'
},
'footer.product': { en: 'Product', 'zh-CN': '产品' },
'footer.resources': { en: 'Resources', 'zh-CN': '资源' },
'footer.company': { en: 'Company', 'zh-CN': '公司' },
'footer.legal': { en: 'Legal', 'zh-CN': '法律' },
'footer.copyright': {
en: 'Comfy Org. All rights reserved.',
'zh-CN': 'Comfy Org. 保留所有权利。'
},
'footer.comfyDesktop': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
'footer.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
'footer.comfyHub': { en: 'ComfyHub', 'zh-CN': 'ComfyHub' },
'footer.pricing': { en: 'Pricing', 'zh-CN': '价格' },
'footer.documentation': { en: 'Documentation', 'zh-CN': '文档' },
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
'footer.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'footer.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'footer.about': { en: 'About', 'zh-CN': '关于' },
'footer.careers': { en: 'Careers', 'zh-CN': '招聘' },
'footer.enterprise': { en: 'Enterprise', 'zh-CN': '企业版' },
'footer.terms': { en: 'Terms of Service', 'zh-CN': '服务条款' },
'footer.privacy': { en: 'Privacy Policy', 'zh-CN': '隐私政策' }
} as const satisfies Record<string, Record<Locale, string>>
type TranslationKey = keyof typeof translations
export function t(key: TranslationKey, locale: Locale = 'en'): string {
return translations[key][locale] ?? translations[key].en
}
export function localePath(path: string, locale: Locale): string {
return locale === 'en' ? path : `/${locale}${path}`
}
export type { Locale }

View File

@@ -4,89 +4,89 @@ import SiteNav from '../../components/SiteNav.vue'
import SiteFooter from '../../components/SiteFooter.vue'
const team = [
{ name: 'comfyanonymous', role: 'ComfyUI 创始人、联合创始人' },
{ name: 'Dr.Lt.Data', role: 'ComfyUI-Manager Impact/Inspire Pack 作者' },
{ name: 'pythongosssss', role: '核心贡献者、ComfyUI-Custom-Scripts 作者' },
{ name: 'yoland68', role: 'ComfyCLI 作者、联合创始人、前 Google' },
{ name: 'robinjhuang', role: 'Comfy Registry 维护者、联合创始人、前 Google Cloud' },
{ name: 'jojodecay', role: 'ComfyUI 活动主持人、社区与合作关系' },
{ name: 'christian-byrne', role: '全栈开发工程师' },
{ name: 'Kosinkadink', role: 'AnimateDiff-Evolved Advanced-ControlNet 作者' },
{ name: 'webfiltered', role: 'Litegraph 库重构者' },
{ name: 'Pablo', role: '产品设计、前 AI 初创公司创始人' },
{ name: 'ComfyUI Wiki (Daxiong)', role: '官方文档和模板' },
{ name: 'ctrlbenlu (Ben)', role: '软件工程师、前机器人领域' },
{ name: 'Purz Beats', role: '动效设计师和机器学习工程师' },
{ name: 'Ricyu (Rich)', role: '软件工程师、前 Meta' },
{ name: 'comfyanonymous', role: 'Creator of ComfyUI, cofounder' },
{ name: 'Dr.Lt.Data', role: 'Creator of ComfyUI-Manager and Impact/Inspire Pack' },
{ name: 'pythongosssss', role: 'Major contributor, creator of ComfyUI-Custom-Scripts' },
{ name: 'yoland68', role: 'Creator of ComfyCLI, cofounder, ex-Google' },
{ name: 'robinjhuang', role: 'Maintains Comfy Registry, cofounder, ex-Google Cloud' },
{ name: 'jojodecay', role: 'ComfyUI event series host, community & partnerships' },
{ name: 'christian-byrne', role: 'Fullstack developer' },
{ name: 'Kosinkadink', role: 'Creator of AnimateDiff-Evolved and Advanced-ControlNet' },
{ name: 'webfiltered', role: 'Overhauled Litegraph library' },
{ name: 'Pablo', role: 'Product Design, ex-AI startup founder' },
{ name: 'ComfyUI Wiki (Daxiong)', role: 'Official docs and templates' },
{ name: 'ctrlbenlu (Ben)', role: 'Software engineer, ex-robotics' },
{ name: 'Purz Beats', role: 'Motion graphics designer and ML Engineer' },
{ name: 'Ricyu (Rich)', role: 'Software engineer, ex-Meta' },
]
const collaborators = [
{ name: 'Yogo', role: '协作者' },
{ name: 'Fill (Machine Delusions)', role: '协作者' },
{ name: 'Julien (MJM)', role: '协作者' },
{ name: 'Yogo', role: 'Collaborator' },
{ name: 'Fill (Machine Delusions)', role: 'Collaborator' },
{ name: 'Julien (MJM)', role: 'Collaborator' },
]
const projects = [
{ name: 'ComfyUI', description: '生成式 AI 工作流的核心节点式界面。' },
{ name: 'ComfyUI Manager', description: '一键安装、更新和管理自定义节点。' },
{ name: 'Comfy Registry', description: '发布和发现自定义节点的官方注册表。' },
{ name: 'Frontends', description: '驱动 ComfyUI 体验的桌面端和 Web 前端。' },
{ name: 'Docs', description: '官方文档、指南和教程。' },
{ name: 'ComfyUI', description: 'The core node-based interface for generative AI workflows.' },
{ name: 'ComfyUI Manager', description: 'Install, update, and manage custom nodes with one click.' },
{ name: 'Comfy Registry', description: 'The official registry for publishing and discovering custom nodes.' },
{ name: 'Frontends', description: 'The desktop and web frontends that power the ComfyUI experience.' },
{ name: 'Docs', description: 'Official documentation, guides, and tutorials.' },
]
const faqs = [
{
q: 'ComfyUI 免费吗?',
a: '是的。ComfyUI 是免费开源的,基于 GPL-3.0 许可证。您可以将其用于个人和商业项目。',
q: 'Is ComfyUI free?',
a: 'Yes. ComfyUI is free and open-source under the GPL-3.0 license. You can use it for personal and commercial projects.',
},
{
q: '谁在开发 ComfyUI',
a: 'ComfyUI comfyanonymous 创建,由一个小而专注的开发团队和社区贡献者共同维护。',
q: 'Who is behind ComfyUI?',
a: 'ComfyUI was created by comfyanonymous and is maintained by a small, dedicated team of developers and community contributors.',
},
{
q: '如何参与贡献?',
a: '查看我们的 GitHub 仓库来报告问题、提交 Pull Request 或构建自定义节点。加入我们的 Discord 社区与其他贡献者交流。',
q: 'How can I contribute?',
a: 'Check out our GitHub repositories to report issues, submit pull requests, or build custom nodes. Join our Discord community to connect with other contributors.',
},
{
q: '未来有什么计划?',
a: '我们专注于让 ComfyUI 成为生成式 AI 的操作系统——提升性能、扩展模型支持,为创作者和开发者打造更好的工具。',
q: 'What are the future plans?',
a: 'We are focused on making ComfyUI the operating system for generative AI — improving performance, expanding model support, and building better tools for creators and developers.',
},
]
---
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<SiteNav locale="zh-CN" client:load />
<BaseLayout title="关于我们 — Comfy" description="Learn about the team and mission behind ComfyUI, the open-source generative AI platform.">
<SiteNav client:load />
<main>
<!-- 主页横幅 -->
<!-- Hero -->
<section class="px-6 pb-24 pt-40 text-center">
<h1 class="mx-auto max-w-4xl text-4xl font-bold leading-tight md:text-6xl">
开创视觉与音频媒体的下一个前沿
Crafting the next frontier of visual and audio media
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg text-smoke-700">
一个开源社区和公司,致力于为生成式 AI 创作者打造最强大的工具。
An open-source community and company building the most powerful tools for generative AI creators.
</p>
</section>
<!-- 我们的使命 -->
<!-- Our Mission -->
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">我们的使命</h2>
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">Our Mission</h2>
<p class="mt-6 text-3xl font-bold md:text-4xl">
我们想打造生成式 AI 的操作系统。
We want to build the operating system for Gen AI.
</p>
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
我们正在构建让创作者完全掌控生成式 AI 的基础工具。
从图像和视频合成到音频生成ComfyUI 提供了一个模块化的
节点式环境,让专业人士和爱好者可以创建、迭代
和部署生产级工作流——没有黑箱。
We're building the foundational tools that give creators full control over generative AI.
From image and video synthesis to audio generation, ComfyUI provides a modular,
node-based environment where professionals and enthusiasts can craft, iterate,
and deploy production-quality workflows — without black boxes.
</p>
</div>
</section>
<!-- 我们做什么? -->
<!-- What Do We Do? -->
<section class="px-6 py-24">
<div class="mx-auto max-w-5xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">我们做什么?</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">What Do We Do?</h2>
<div class="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<div class="rounded-xl border border-white/10 bg-charcoal-600 p-6">
@@ -98,23 +98,24 @@ const faqs = [
</div>
</section>
<!-- 我们是谁 -->
<!-- Who We Are -->
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-3xl font-bold md:text-4xl">我们是谁</h2>
<h2 class="text-3xl font-bold md:text-4xl">Who We Are</h2>
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
ComfyUI 最初是 comfyanonymous 的个人项目,后来发展成为一个全球性的
创作者、开发者和研究者社区。今天Comfy Org 是一个位于旧金山的小型扁平化团队,
由相信开源 AI 工具的投资者支持。我们与令人难以置信的贡献者社区一起工作,
他们构建自定义节点、分享工作流,并不断突破生成式 AI 的边界。
ComfyUI started as a personal project by comfyanonymous and grew into a global community
of creators, developers, and researchers. Today, Comfy Org is a small, flat team based in
San Francisco, backed by investors who believe in open-source AI tooling. We work
alongside an incredible community of contributors who build custom nodes, share workflows,
and push the boundaries of what's possible with generative AI.
</p>
</div>
</section>
<!-- 团队 -->
<!-- Team -->
<section class="px-6 py-24">
<div class="mx-auto max-w-6xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">团队</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">Team</h2>
<div class="mt-12 grid gap-6 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{team.map((member) => (
<div class="rounded-xl border border-white/10 p-5 text-center">
@@ -127,10 +128,10 @@ const faqs = [
</div>
</section>
<!-- 协作者 -->
<!-- Collaborators -->
<section class="bg-charcoal-800 px-6 py-16">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-2xl font-bold">协作者</h2>
<h2 class="text-2xl font-bold">Collaborators</h2>
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
{collaborators.map((person) => (
<div class="text-center">
@@ -142,10 +143,10 @@ const faqs = [
</div>
</section>
<!-- 常见问题 -->
<!-- FAQs -->
<section class="px-6 py-24">
<div class="mx-auto max-w-3xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">常见问题</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">FAQs</h2>
<div class="mt-12 space-y-10">
{faqs.map((faq) => (
<div>
@@ -157,19 +158,19 @@ const faqs = [
</div>
</section>
<!-- 加入我们 CTA -->
<!-- Join Our Team CTA -->
<section class="bg-charcoal-800 px-6 py-24 text-center">
<h2 class="text-3xl font-bold md:text-4xl">加入我们的团队</h2>
<h2 class="text-3xl font-bold md:text-4xl">Join Our Team</h2>
<p class="mx-auto mt-4 max-w-xl text-smoke-700">
我们正在寻找热衷于开源、生成式 AI 和打造优秀开发者工具的人。
We're looking for people who are passionate about open-source, generative AI, and building great developer tools.
</p>
<a
href="/careers"
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"
>
查看开放职位
View Open Positions
</a>
</section>
</main>
<SiteFooter locale="zh-CN" />
<SiteFooter />
</BaseLayout>

View File

@@ -78,7 +78,7 @@ const questions = [
title="招聘 — Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
>
<SiteNav locale="zh-CN" client:load />
<SiteNav client:load />
<main>
<!-- Hero -->
<section class="px-6 pb-24 pt-40">
@@ -196,5 +196,5 @@ const questions = [
</div>
</section>
</main>
<SiteFooter locale="zh-CN" />
<SiteFooter />
</BaseLayout>

View File

@@ -32,7 +32,7 @@ const cards = [
---
<BaseLayout title="下载 — Comfy">
<SiteNav locale="zh-CN" client:load />
<SiteNav client:load />
<main class="mx-auto max-w-5xl px-6 py-32 text-center">
<h1 class="text-4xl font-bold text-white md:text-5xl">
下载 ComfyUI
@@ -76,5 +76,5 @@ const cards = [
</p>
</div>
</main>
<SiteFooter locale="zh-CN" />
<SiteFooter />
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout title="作品集 — Comfy">
<SiteNav locale="zh-CN" client:load />
<SiteNav client:load />
<main class="bg-black text-white">
<!-- Hero -->
<section class="mx-auto max-w-5xl px-6 pb-16 pt-32 text-center">
@@ -39,5 +39,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
</a>
</section>
</main>
<SiteFooter locale="zh-CN" />
<SiteFooter />
</BaseLayout>

View File

@@ -16,19 +16,19 @@ import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
<SiteNav locale="zh-CN" client:load />
<SiteNav client:load />
<main>
<HeroSection locale="zh-CN" />
<SocialProofBar locale="zh-CN" />
<ProductShowcase locale="zh-CN" />
<ValuePillars locale="zh-CN" />
<UseCaseSection locale="zh-CN" client:visible />
<CaseStudySpotlight locale="zh-CN" />
<TestimonialsSection locale="zh-CN" client:visible />
<GetStartedSection locale="zh-CN" />
<CTASection locale="zh-CN" />
<ManifestoSection locale="zh-CN" />
<AcademySection locale="zh-CN" />
<HeroSection />
<SocialProofBar />
<ProductShowcase />
<ValuePillars />
<UseCaseSection client:visible />
<CaseStudySpotlight />
<TestimonialsSection client:visible />
<GetStartedSection />
<CTASection />
<ManifestoSection />
<AcademySection />
</main>
<SiteFooter locale="zh-CN" />
<SiteFooter />
</BaseLayout>

View File

@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
noindex
>
<SiteNav locale="zh-CN" client:load />
<SiteNav client:load />
<main class="mx-auto max-w-3xl px-6 py-24">
<h1 class="text-3xl font-bold text-white">隐私政策</h1>
<p class="mt-2 text-sm text-smoke-500">生效日期2025年4月18日</p>
@@ -229,5 +229,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
</p>
</section>
</main>
<SiteFooter locale="zh-CN" />
<SiteFooter />
</BaseLayout>

View File

@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
description="ComfyUI 及相关 Comfy 服务的服务条款。"
noindex
>
<SiteNav locale="zh-CN" client:load />
<SiteNav client:load />
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
<header class="mb-16">
<h1 class="text-3xl font-bold text-white">服务条款</h1>
@@ -216,5 +216,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
</div>
</section>
</main>
<SiteFooter locale="zh-CN" />
<SiteFooter />
</BaseLayout>

View File

@@ -1,284 +0,0 @@
{
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
"revision": 0,
"last_node_id": 13,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [120, 180],
"size": [210, 168],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Alpha\n"]
},
{
"id": 12,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [420, 180],
"size": [210, 168],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Beta\n"]
},
{
"id": 13,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [720, 180],
"size": [210, 168],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Gamma\n"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
},
"outputNode": {
"id": -20,
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [10],
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [11],
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [12],
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [13],
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [14],
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [15],
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [661.59912109375, 314.13336181640625],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": { "name": "text" },
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
},
{
"id": 11,
"type": "KSampler",
"pos": [674.1234741210938, 570.5839233398438],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -34,7 +34,6 @@ import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
@@ -182,7 +181,6 @@ export class ComfyPage {
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -234,7 +232,6 @@ export class ComfyPage {
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
this.cloudAuth = new CloudAuthHelper(page)
}
get visibleToasts() {
@@ -447,10 +444,6 @@ export const comfyPageFixture = base.extend<{
console.error(e)
}
if (testInfo.tags.includes('@cloud')) {
await comfyPage.cloudAuth.mockAuth()
}
await comfyPage.setup()
const needsPerf =

View File

@@ -0,0 +1,97 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '../selectors'
const ids = TestIds.outputHistory
export class OutputHistoryComponent {
constructor(private readonly page: Page) {}
get outputs(): Locator {
return this.page.getByTestId(ids.outputs)
}
get welcome(): Locator {
return this.page.getByTestId(ids.welcome)
}
get outputInfo(): Locator {
return this.page.getByTestId(ids.outputInfo)
}
get activeQueue(): Locator {
return this.page.getByTestId(ids.activeQueue)
}
get queueBadge(): Locator {
return this.page.getByTestId(ids.queueBadge)
}
get inProgressItems(): Locator {
return this.page.getByTestId(ids.inProgressItem)
}
get historyItems(): Locator {
return this.page.getByTestId(ids.historyItem)
}
get skeletons(): Locator {
return this.page.getByTestId(ids.skeleton)
}
get latentPreviews(): Locator {
return this.page.getByTestId(ids.latentPreview)
}
get imageOutputs(): Locator {
return this.page.getByTestId(ids.imageOutput)
}
get videoOutputs(): Locator {
return this.page.getByTestId(ids.videoOutput)
}
/** The currently selected (checked) in-progress item. */
get selectedInProgressItem(): Locator {
return this.page.locator(
`[data-testid="${ids.inProgressItem}"][data-state="checked"]`
)
}
/** The currently selected (checked) history item. */
get selectedHistoryItem(): Locator {
return this.page.locator(
`[data-testid="${ids.historyItem}"][data-state="checked"]`
)
}
/** The header-level progress bar. */
get headerProgressBar(): Locator {
return this.page.getByTestId(ids.headerProgressBar)
}
/** The in-progress item's progress bar (inside the thumbnail). */
get itemProgressBar(): Locator {
return this.inProgressItems.first().getByTestId(ids.itemProgressBar)
}
/** Overall progress in the header bar. */
get headerOverallProgress(): Locator {
return this.headerProgressBar.getByTestId(ids.progressOverall)
}
/** Node progress in the header bar. */
get headerNodeProgress(): Locator {
return this.headerProgressBar.getByTestId(ids.progressNode)
}
/** Overall progress in the in-progress item bar. */
get itemOverallProgress(): Locator {
return this.itemProgressBar.getByTestId(ids.progressOverall)
}
/** Node progress in the in-progress item bar. */
get itemNodeProgress(): Locator {
return this.itemProgressBar.getByTestId(ids.progressNode)
}
}

View File

@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
@@ -14,6 +15,7 @@ export class AppModeHelper {
readonly footer: BuilderFooterHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly widgets: AppModeWidgetHelper
constructor(private readonly comfyPage: ComfyPage) {
@@ -21,6 +23,7 @@ export class AppModeHelper {
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.widgets = new AppModeWidgetHelper(comfyPage)
}
@@ -93,6 +96,10 @@ export class AppModeHelper {
await this.toggleAppMode()
}
get cancelRunButton(): Locator {
return this.page.getByTestId(TestIds.outputHistory.cancelRun)
}
/** The "Connect an output" popover shown when saving without outputs. */
get connectOutputPopover(): Locator {
return this.page.getByTestId(TestIds.builder.connectOutputPopover)

View File

@@ -1,171 +0,0 @@
import type { Page } from '@playwright/test'
/**
* Mocks Firebase authentication for cloud E2E tests.
*
* The cloud build's router guard waits for Firebase `onAuthStateChanged`
* to fire, then checks `getAuthHeader()`. In CI no Firebase project is
* configured, so the user is never authenticated and the app redirects
* to `/cloud/login`.
*
* This helper seeds Firebase's IndexedDB persistence layer with a mock
* user and intercepts the Firebase REST APIs (securetoken, identitytoolkit)
* so the SDK believes a user is signed in. Must be called before navigation.
*/
export class CloudAuthHelper {
constructor(private readonly page: Page) {}
/**
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
*/
async mockAuth(): Promise<void> {
await this.seedFirebaseIndexedDB()
await this.mockFirebaseEndpoints()
}
/**
* Navigate to a lightweight same-origin page to seed Firebase's
* IndexedDB persistence with a mock user. This ensures the data
* is written before the app loads and Firebase reads it.
*
* Firebase auth uses `browserLocalPersistence` which stores data in
* IndexedDB database `firebaseLocalStorageDb`, object store
* `firebaseLocalStorage`, keyed by `firebase:authUser:<apiKey>:<appName>`.
*/
private async seedFirebaseIndexedDB(): Promise<void> {
// Navigate to a lightweight endpoint to get a same-origin context
await this.page.goto('http://localhost:8188/api/users')
await this.page.evaluate(() => {
const MOCK_USER_DATA = {
uid: 'test-user-e2e',
email: 'e2e@test.comfy.org',
displayName: 'E2E Test User',
emailVerified: true,
isAnonymous: false,
providerData: [
{
providerId: 'google.com',
uid: 'test-user-e2e',
displayName: 'E2E Test User',
email: 'e2e@test.comfy.org',
phoneNumber: null,
photoURL: null
}
],
stsTokenManager: {
refreshToken: 'mock-refresh-token',
accessToken: 'mock-firebase-id-token',
expirationTime: Date.now() + 60 * 60 * 1000
},
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
appName: '[DEFAULT]'
}
const DB_NAME = 'firebaseLocalStorageDb'
const STORE_NAME = 'firebaseLocalStorage'
const KEY = `firebase:authUser:${MOCK_USER_DATA.apiKey}:${MOCK_USER_DATA.appName}`
return new Promise<void>((resolve, reject) => {
const request = indexedDB.open(DB_NAME)
request.onerror = () => reject(request.error)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
request.onsuccess = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.close()
const upgradeReq = indexedDB.open(DB_NAME, db.version + 1)
upgradeReq.onerror = () => reject(upgradeReq.error)
upgradeReq.onupgradeneeded = () => {
const upgradedDb = upgradeReq.result
if (!upgradedDb.objectStoreNames.contains(STORE_NAME)) {
upgradedDb.createObjectStore(STORE_NAME)
}
}
upgradeReq.onsuccess = () => {
const upgradedDb = upgradeReq.result
const tx = upgradedDb.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(
{ fpiVersion: '1', value: MOCK_USER_DATA },
KEY
)
tx.oncomplete = () => {
upgradedDb.close()
resolve()
}
tx.onerror = () => reject(tx.error)
}
return
}
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(
{ fpiVersion: '1', value: MOCK_USER_DATA },
KEY
)
tx.oncomplete = () => {
db.close()
resolve()
}
tx.onerror = () => reject(tx.error)
}
})
})
}
/**
* Intercept Firebase Auth REST API endpoints so the SDK can
* "refresh" the mock user's token without real credentials.
*/
private async mockFirebaseEndpoints(): Promise<void> {
await this.page.route('**/securetoken.googleapis.com/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
access_token: 'mock-access-token',
expires_in: '3600',
token_type: 'Bearer',
refresh_token: 'mock-refresh-token',
id_token: 'mock-firebase-id-token',
user_id: 'test-user-e2e',
project_id: 'dreamboothy-dev'
})
})
)
await this.page.route('**/identitytoolkit.googleapis.com/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
kind: 'identitytoolkit#GetAccountInfoResponse',
users: [
{
localId: 'test-user-e2e',
email: 'e2e@test.comfy.org',
displayName: 'E2E Test User',
emailVerified: true,
validSince: '0',
lastLoginAt: String(Date.now()),
createdAt: String(Date.now()),
lastRefreshAt: new Date().toISOString()
}
]
})
})
)
await this.page.route('**/__/auth/**', (route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html><body></body></html>'
})
)
}
}

View File

@@ -0,0 +1,211 @@
import type { WebSocketRoute } from '@playwright/test'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '../ComfyPage'
import { createMockJob } from './AssetsHelper'
/**
* Helper for simulating prompt execution in e2e tests.
*/
export class ExecutionHelper {
private jobCounter = 0
private readonly completedJobs: RawJobListItem[] = []
private readonly page: ComfyPage['page']
private readonly command: ComfyPage['command']
private readonly assets: ComfyPage['assets']
constructor(
comfyPage: ComfyPage,
private readonly ws: WebSocketRoute
) {
this.page = comfyPage.page
this.command = comfyPage.command
this.assets = comfyPage.assets
}
/**
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
* the synthetic job ID.
*
* The app receives a valid PromptResponse so storeJob() fires
* and registers the job against the active workflow path.
*/
async run(): Promise<string> {
const jobId = `test-job-${++this.jobCounter}`
let fulfilled!: () => void
const prompted = new Promise<void>((r) => {
fulfilled = r
})
await this.page.route(
'**/api/prompt',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: jobId,
node_errors: {}
})
})
fulfilled()
},
{ times: 1 }
)
await this.command.executeCommand('Comfy.QueuePrompt')
await prompted
return jobId
}
/**
* Send a binary `b_preview_with_metadata` WS message (type 4).
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
*/
latentPreview(jobId: string, nodeId: string): void {
const metadata = JSON.stringify({
node_id: nodeId,
display_node_id: nodeId,
parent_node_id: nodeId,
real_node_id: nodeId,
prompt_id: jobId,
image_type: 'image/png'
})
const metadataBytes = new TextEncoder().encode(metadata)
// 1x1 red PNG
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==',
'base64'
)
// Binary format: [type:uint32][metadataLength:uint32][metadata][imageData]
const buf = new ArrayBuffer(8 + metadataBytes.length + png.length)
const view = new DataView(buf)
view.setUint32(0, 4) // type 4 = PREVIEW_IMAGE_WITH_METADATA
view.setUint32(4, metadataBytes.length)
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
this.ws.send(Buffer.from(buf))
}
/** Send `execution_start` WS event. */
executionStart(jobId: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_start',
data: { prompt_id: jobId, timestamp: Date.now() }
})
)
}
/** Send `executing` WS event to signal which node is currently running. */
executing(jobId: string, nodeId: string | null): void {
this.ws.send(
JSON.stringify({
type: 'executing',
data: { prompt_id: jobId, node: nodeId }
})
)
}
/** Send `executed` WS event with node output. */
executed(
jobId: string,
nodeId: string,
output: Record<string, unknown>
): void {
this.ws.send(
JSON.stringify({
type: 'executed',
data: {
prompt_id: jobId,
node: nodeId,
display_node: nodeId,
output
}
})
)
}
/** Send `execution_success` WS event. */
executionSuccess(jobId: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_success',
data: { prompt_id: jobId, timestamp: Date.now() }
})
)
}
/** Send `execution_error` WS event. */
executionError(jobId: string, nodeId: string, message: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_error',
data: {
prompt_id: jobId,
timestamp: Date.now(),
node_id: nodeId,
node_type: 'Unknown',
exception_message: message,
exception_type: 'RuntimeError',
traceback: []
}
})
)
}
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.ws.send(
JSON.stringify({
type: 'progress',
data: { prompt_id: jobId, node: nodeId, value, max }
})
)
}
/**
* Complete a job by adding it to mock history, sending execution_success,
* and triggering a history refresh via a status event.
*
* Requires an {@link AssetsHelper} to be passed in the constructor.
*/
async completeWithHistory(
jobId: string,
nodeId: string,
filename: string
): Promise<void> {
this.completedJobs.push(
createMockJob({
id: jobId,
preview_output: {
filename,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
}
})
)
await this.assets.mockOutputHistory(this.completedJobs)
this.executionSuccess(jobId)
// Trigger queue/history refresh
this.status(0)
}
/** Send `status` WS event to update queue count. */
status(queueRemaining: number): void {
this.ws.send(
JSON.stringify({
type: 'status',
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
})
)
}
}

View File

@@ -63,8 +63,7 @@ export const TestIds = {
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel'
missingMediaLocateButton: 'missing-media-locate-button'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -130,6 +129,24 @@ export const TestIds = {
outputPlaceholder: 'builder-output-placeholder',
connectOutputPopover: 'builder-connect-output-popover'
},
outputHistory: {
outputs: 'linear-outputs',
welcome: 'linear-welcome',
outputInfo: 'linear-output-info',
activeQueue: 'linear-job',
queueBadge: 'linear-job-badge',
inProgressItem: 'linear-in-progress-item',
historyItem: 'linear-history-item',
skeleton: 'linear-skeleton',
latentPreview: 'linear-latent-preview',
imageOutput: 'linear-image-output',
videoOutput: 'linear-video-output',
cancelRun: 'linear-cancel-run',
headerProgressBar: 'linear-header-progress-bar',
itemProgressBar: 'linear-item-progress-bar',
progressOverall: 'linear-progress-overall',
progressNode: 'linear-progress-node'
},
appMode: {
widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome',
@@ -174,6 +191,7 @@ export type TestIdValue =
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<

View File

@@ -1,53 +1,31 @@
import { test as base } from '@playwright/test'
import type { WebSocketRoute } from '@playwright/test'
export const webSocketFixture = base.extend<{
ws: { trigger(data: unknown, url?: string): Promise<void> }
getWebSocket: () => Promise<WebSocketRoute>
}>({
ws: [
async ({ page }, use) => {
// Each time a page loads, to catch navigations
page.on('load', async () => {
await page.evaluate(function () {
// Create a wrapper for WebSocket that stores them globally
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = (window.__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor(
...rest: ConstructorParameters<typeof window.WebSocket>
) {
super(...rest)
store[this.url] = this
}
}
getWebSocket: [
async ({ context }, use) => {
let latest: WebSocketRoute | undefined
let resolve: ((ws: WebSocketRoute) => void) | undefined
await context.routeWebSocket(/\/ws/, (ws) => {
const server = ws.connectToServer()
server.onMessage((message) => {
ws.send(message)
})
latest = ws
resolve?.(ws)
})
await use({
async trigger(data, url) {
// Trigger a websocket event on the page
await page.evaluate(
function ([data, url]) {
if (!url) {
// If no URL specified, use page URL
const u = new URL(window.location.href)
u.hash = ''
u.protocol = 'ws:'
u.pathname = '/'
url = u.toString() + 'ws'
}
const ws: WebSocket = window.__ws__![url]
ws.dispatchEvent(
new MessageEvent('message', {
data
})
)
},
[JSON.stringify(data), url]
)
}
await use(() => {
if (latest) return Promise.resolve(latest)
return new Promise<WebSocketRoute>((r) => {
resolve = r
})
})
},
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
{ auto: true }
]
})

View File

@@ -1,8 +1,15 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
type PromotedWidgetEntry = [string, string]
export type PromotedWidgetEntry = [string, string]
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
export interface PromotedWidgetSnapshot {
proxyWidgets: PromotedWidgetEntry[]
widgetNames: string[]
}
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
return (
Array.isArray(entry) &&
entry.length === 2 &&
@@ -11,7 +18,9 @@ function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
)
}
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
export function normalizePromotedWidgets(
value: unknown
): PromotedWidgetEntry[] {
if (!Array.isArray(value)) return []
return value.filter(isPromotedWidgetEntry)
}
@@ -28,6 +37,28 @@ export async function getPromotedWidgets(
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetSnapshot(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetSnapshot> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return {
proxyWidgets: node?.properties?.proxyWidgets ?? [],
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
}
}, nodeId)
return {
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
widgetNames: Array.isArray(raw.widgetNames)
? raw.widgetNames.filter(
(name): name is string => typeof name === 'string'
)
: []
}
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string
@@ -44,7 +75,7 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
return entry[1].startsWith('$$')
}
@@ -56,6 +87,14 @@ export async function getPseudoPreviewWidgets(
return widgets.filter(isPseudoPreviewEntry)
}
export async function getNonPreviewPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,

View File

@@ -1,7 +1,6 @@
import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { StatusWsMessage } from '@/schemas/apiSchema'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { webSocketFixture } from '@e2e/fixtures/ws'
import type { WorkspaceStore } from '@e2e/types/globals'
@@ -18,8 +17,10 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
*/
test('Does not auto-queue multiple changes at a time', async ({
comfyPage,
ws
getWebSocket
}) => {
const ws = await getWebSocket()
// Enable change auto-queue mode
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
expect(await queueOpts.getMode()).toBe('disabled')
@@ -62,17 +63,19 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
}
// Trigger a status websocket message
const triggerStatus = async (queueSize: number) => {
await ws.trigger({
type: 'status',
data: {
status: {
exec_info: {
queue_remaining: queueSize
const triggerStatus = (queueSize: number) => {
ws.send(
JSON.stringify({
type: 'status',
data: {
status: {
exec_info: {
queue_remaining: queueSize
}
}
}
}
} as StatusWsMessage)
})
)
}
// Extract the width from the queue response
@@ -104,8 +107,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
).toBe(1)
// Trigger a status update so auto-queue re-runs
await triggerStatus(1)
await triggerStatus(0)
triggerStatus(1)
triggerStatus(0)
// Ensure the queued width is the last queued value
expect(

View File

@@ -1,7 +1,7 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
} from '@e2e/fixtures/ComfyPage'
test.describe('App mode welcome states', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,166 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { mockSystemStats } from '../../fixtures/data/systemStats'
const MOCK_COMFYUI_VERSION = '9.99.0-e2e-test'
test.describe('Settings dialog', { tag: '@ui' }, () => {
test('About panel renders mocked version from server', async ({
comfyPage
}) => {
const stats = {
...mockSystemStats,
system: {
...mockSystemStats.system,
comfyui_version: MOCK_COMFYUI_VERSION
}
}
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({ json: stats })
})
await comfyPage.setup()
const dialog = comfyPage.settingDialog
await dialog.open()
await dialog.goToAboutPanel()
const aboutPanel = comfyPage.page.getByTestId('about-panel')
await expect(aboutPanel).toBeVisible()
await expect(aboutPanel).toContainText(MOCK_COMFYUI_VERSION)
await expect(aboutPanel).toContainText('ComfyUI_frontend')
})
test('Toggling a boolean setting through UI persists the value', async ({
comfyPage
}) => {
const settingId = 'Comfy.Validation.Workflows'
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
const dialog = comfyPage.settingDialog
await dialog.open()
try {
await dialog.searchBox.fill('Validate workflows')
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
await settingRow.locator('.p-toggleswitch').click()
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
test('Can be closed via close button', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
await expect(dialog.root).toBeVisible()
await dialog.close()
await expect(dialog.root).not.toBeVisible()
})
test('Escape key closes dialog', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
await expect(dialog.root).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).not.toBeVisible()
})
test('Search filters settings list', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
const settingItems = dialog.root.locator('[data-setting-id]')
const countBeforeSearch = await settingItems.count()
await dialog.searchBox.fill('Validate workflows')
await expect
.poll(() => settingItems.count())
.toBeLessThan(countBeforeSearch)
})
test('Search can be cleared to restore all settings', async ({
comfyPage
}) => {
const dialog = comfyPage.settingDialog
await dialog.open()
const settingItems = dialog.root.locator('[data-setting-id]')
const countBeforeSearch = await settingItems.count()
await dialog.searchBox.fill('Validate workflows')
await expect
.poll(() => settingItems.count())
.toBeLessThan(countBeforeSearch)
await dialog.searchBox.clear()
await expect.poll(() => settingItems.count()).toBe(countBeforeSearch)
})
test('Category navigation changes content area', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
const firstCategory = dialog.categories.first()
const firstCategoryName = await firstCategory.textContent()
await firstCategory.click()
const firstContent = await dialog.contentArea.textContent()
// Find a different category to click
const categoryCount = await dialog.categories.count()
let switched = false
for (let i = 1; i < categoryCount; i++) {
const cat = dialog.categories.nth(i)
const catName = await cat.textContent()
if (catName !== firstCategoryName) {
await cat.click()
await expect
.poll(() => dialog.contentArea.textContent())
.not.toBe(firstContent)
switched = true
break
}
}
expect(switched).toBe(true)
})
test('Dropdown setting can be changed and persists', async ({
comfyPage
}) => {
const settingId = 'Comfy.UseNewMenu'
const initialValue = await comfyPage.settings.getSetting<string>(settingId)
const dialog = comfyPage.settingDialog
await dialog.open()
try {
await dialog.searchBox.fill('Use new menu')
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
// Click the PrimeVue Select to open the dropdown
await settingRow.locator('.p-select').click()
const overlay = comfyPage.page.locator('.p-select-overlay')
await expect(overlay).toBeVisible()
// Pick the option that is not the current value
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
await overlay
.locator(`.p-select-option-label:text-is("${targetValue}")`)
.click()
await expect
.poll(() => comfyPage.settings.getSetting<string>(settingId))
.toBe(targetValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
})

View File

@@ -1,352 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { AssetInfo } from '../../../src/schemas/apiSchema'
import { comfyPageFixture } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
interface PublishRecord {
workflow_id: string
share_id: string | null
listed: boolean
publish_time: string | null
}
const PUBLISHED_RECORD: PublishRecord = {
workflow_id: 'wf-1',
share_id: 'share-abc',
listed: false,
publish_time: new Date(Date.now() + 60_000).toISOString()
}
const PRIVATE_ASSET: AssetInfo = {
id: 'asset-1',
name: 'photo.png',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const test = comfyPageFixture
/**
* Enable the workflow_sharing_enabled server feature flag at runtime.
* FeatureFlagHelper.mockServerFeatures() intercepts `/api/features` but the
* flags are already loaded by the time tests run, so direct mutation of the
* reactive ref is the only reliable approach for server-side flags.
*/
async function enableWorkflowSharing(page: Page): Promise<void> {
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
workflow_sharing_enabled: true
}
})
}
async function mockPublishStatus(
page: Page,
record: PublishRecord | null
): Promise<void> {
await page.route('**/api/userdata/*/publish', async (route) => {
if (route.request().method() === 'GET') {
if (!record || !record.share_id) {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(record)
})
}
} else {
await route.fallback()
}
})
}
async function mockPublishWorkflow(
page: Page,
result: PublishRecord
): Promise<void> {
await page.route('**/api/userdata/*/publish', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(result)
})
} else {
await route.fallback()
}
})
}
async function mockShareableAssets(
page: Page,
assets: AssetInfo[] = []
): Promise<void> {
await page.route('**/api/assets/from-workflow', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ assets })
})
})
}
/**
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
* or auth-triggered modals by pressing Escape until they clear.
*/
async function dismissOverlays(page: Page): Promise<void> {
const mask = page.locator('.p-dialog-mask')
for (let attempt = 0; attempt < 3; attempt++) {
if ((await mask.count()) === 0) break
await page.keyboard.press('Escape')
await mask
.first()
.waitFor({ state: 'hidden', timeout: 2000 })
.catch(() => {})
}
}
/**
* Save the active workflow via the topbar Save menu action.
* Mocks the userdata POST endpoint to avoid real server calls in tests.
*/
async function saveAndWait(
comfyPage: {
page: Page
menu: { topbar: { saveWorkflow: (name: string) => Promise<void> } }
},
workflowName: string
): Promise<void> {
const filename =
workflowName + (workflowName.endsWith('.json') ? '' : '.json')
await comfyPage.page.route(
/\/api\/userdata\/workflows(%2F|\/).*$/,
async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
path: `workflows/${filename}`,
size: 1024,
modified: Date.now()
})
})
} else {
await route.fallback()
}
}
)
await comfyPage.menu.topbar.saveWorkflow(workflowName)
}
async function openShareDialog(page: Page): Promise<void> {
await enableWorkflowSharing(page)
await dismissOverlays(page)
const shareButton = page.getByRole('button', { name: 'Share workflow' })
await shareButton.click()
}
function getShareDialog(page: Page) {
return page.getByRole('dialog')
}
test.describe('Share Workflow Dialog', { tag: '@cloud' }, () => {
test('should show unsaved state for a new workflow', async ({
comfyPage
}) => {
const { page } = comfyPage
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: /save workflow/i })
).toBeVisible()
})
test('should show ready state with create link button', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-ready'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: /create a link/i })
).toBeVisible()
})
test('should show shared state with copy URL after publishing', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-shared'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, PUBLISHED_RECORD)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('textbox', { name: /share.*url/i })
).toBeVisible()
})
test('should show stale state with update link button', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-stale'
await saveAndWait(comfyPage, workflowName)
const staleRecord: PublishRecord = {
...PUBLISHED_RECORD,
publish_time: '2020-01-01T00:00:00Z'
}
await mockPublishStatus(page, staleRecord)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: /update\s+link/i })
).toBeVisible()
})
test('should close dialog when close button is clicked', async ({
comfyPage
}) => {
const { page } = comfyPage
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: /close/i }).click()
await expect(dialog).toBeHidden()
})
test('should create link and transition to shared state', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-create'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await mockPublishWorkflow(page, PUBLISHED_RECORD)
await openShareDialog(page)
const dialog = getShareDialog(page)
const createButton = dialog.getByRole('button', { name: /create a link/i })
await expect(createButton).toBeVisible()
await createButton.click()
await expect(
dialog.getByRole('textbox', { name: /share.*url/i })
).toBeVisible()
})
test('should show tab buttons when comfyHubUploadEnabled is true', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
comfyhub_upload_enabled: true
}
})
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('tab', { name: /share/i })).toBeVisible()
await expect(dialog.getByRole('tab', { name: /publish/i })).toBeVisible()
})
test('should switch between share link and publish tabs', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
comfyhub_upload_enabled: true
}
})
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await dialog.getByRole('tab', { name: /publish/i }).click()
const publishPanel = dialog.getByTestId(TestIds.dialogs.publishTabPanel)
await expect(publishPanel).toBeVisible()
await dialog.getByRole('tab', { name: /share/i }).click()
await expect(publishPanel).toBeHidden()
})
test('should require acknowledgment before publishing private assets', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-ack'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, null)
await mockShareableAssets(page, [PRIVATE_ASSET])
await openShareDialog(page)
const dialog = getShareDialog(page)
const createButton = dialog.getByRole('button', { name: /create a link/i })
await expect(createButton).toBeDisabled()
await dialog.getByRole('checkbox').check()
await expect(createButton).toBeEnabled()
})
})

View File

@@ -0,0 +1,413 @@
import type { WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { comfyPageFixture, comfyExpect as expect } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import { ExecutionHelper } from '../fixtures/helpers/ExecutionHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
// Node IDs from the default workflow (browser_tests/assets/default.json, 7 nodes)
const SAVE_IMAGE_NODE = '9'
const KSAMPLER_NODE = '3'
const ALL_NODE_IDS = ['4', '6', '7', '5', KSAMPLER_NODE, '8', SAVE_IMAGE_NODE]
/** Queue a prompt, intercept it, and send execution_start. */
async function startExecution(
comfyPage: ComfyPage,
ws: WebSocketRoute,
exec?: ExecutionHelper
) {
exec ??= new ExecutionHelper(comfyPage, ws)
const jobId = await exec.run()
// Allow storeJob() to complete before sending WS events
await comfyPage.nextFrame()
exec.executionStart(jobId)
return { exec, jobId }
}
function imageOutput(...filenames: string[]) {
return {
images: filenames.map((filename) => ({
filename,
subfolder: '',
type: 'output'
}))
}
}
test.describe('Output History', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await comfyPage.nextFrame()
})
test('Skeleton appears on execution start', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
})
test('Latent preview replaces skeleton', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
await expect(
comfyPage.appMode.outputHistory.latentPreviews.first()
).toBeVisible()
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
})
test('Image output replaces skeleton on executed', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('test_output.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
})
test('Multiple outputs from single execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(
jobId,
SAVE_IMAGE_NODE,
imageOutput('output_001.png', 'output_002.png', 'output_003.png')
)
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(3)
})
test('Video output renders video element', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(jobId, SAVE_IMAGE_NODE, {
gifs: [{ filename: 'output.mp4', subfolder: '', type: 'output' }]
})
await expect(
comfyPage.appMode.outputHistory.videoOutputs.first()
).toBeVisible()
})
test('Cancel button sends interrupt during execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
const job: RawJobListItem = {
id: jobId,
status: 'in_progress',
create_time: Date.now() / 1000,
priority: 0
}
await comfyPage.page.route(
/\/api\/jobs\?status=in_progress/,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: [job],
pagination: { offset: 0, limit: 200, total: 1, has_more: false }
})
})
},
{ times: 1 }
)
// Trigger queue refresh
exec.status(1)
await comfyPage.nextFrame()
await expect(comfyPage.appMode.cancelRunButton).toBeVisible()
await comfyPage.page.route('**/interrupt', (route) =>
route.fulfill({ status: 200 })
)
const interruptRequest = comfyPage.page.waitForRequest('**/interrupt')
await comfyPage.appMode.cancelRunButton.click()
await interruptRequest
})
test('Full execution lifecycle cleans up in-progress items', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Skeleton appears
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
// Latent preview replaces skeleton
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
await expect(
comfyPage.appMode.outputHistory.latentPreviews.first()
).toBeVisible()
// Image output replaces latent
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('lifecycle_out.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
// Job completes with history mock - in-progress items fully resolved
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'lifecycle_out.png')
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
// Output now appears as a history item
await expect(
comfyPage.appMode.outputHistory.historyItems.first()
).toBeVisible()
})
test('Auto-selection follows latest in-progress item', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Skeleton is auto-selected
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem
).toBeVisible()
// First image is auto-selected
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /first\.png/)
// Second image arrives - selection auto-follows without user click
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /second\.png/)
})
test('Clicking item breaks auto-follow during execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Send first image
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(1)
// Click the first image to break auto-follow
await comfyPage.appMode.outputHistory.inProgressItems.first().click()
// Send second image - selection should NOT move to it
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(2)
// The first item should still be selected (not auto-followed to second)
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem
).toHaveCount(1)
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /first\.png/)
})
test('Non-output node executed events are filtered', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
// KSampler is not an output node - should be filtered
exec.executed(jobId, KSAMPLER_NODE, imageOutput('ksampler_out.png'))
await comfyPage.nextFrame()
// KSampler output should not create image outputs
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(0)
// Now send from the actual output node (SaveImage)
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('save_image_out.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
})
test('In-progress items are outside the scrollable area', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
// Complete one execution with 100 image outputs
const { exec, jobId } = await startExecution(comfyPage, ws)
exec.executed(
jobId,
SAVE_IMAGE_NODE,
imageOutput(
...Array.from(
{ length: 100 },
(_, i) => `image_${String(i).padStart(3, '0')}.png`
)
)
)
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'image_000.png')
await expect(comfyPage.appMode.outputHistory.historyItems).toHaveCount(100)
// First history item is visible before scrolling
const firstItem = comfyPage.appMode.outputHistory.historyItems.first()
await expect(firstItem).toBeInViewport()
// Scroll the history feed all the way to the right
await comfyPage.appMode.outputHistory.outputs.evaluate((el) => {
el.scrollLeft = el.scrollWidth
})
// First history item is now off-screen
await expect(firstItem).not.toBeInViewport()
// Start a new execution to get an in-progress item
await startExecution(comfyPage, ws, exec)
// In-progress item is visible despite scrolling
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeInViewport()
})
test('Execution error cleans up in-progress items', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executionError(jobId, KSAMPLER_NODE, 'Test error')
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
})
test('Progress bars update for both node and overall progress', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
const {
inProgressItems,
headerOverallProgress,
headerNodeProgress,
itemOverallProgress,
itemNodeProgress
} = comfyPage.appMode.outputHistory
await expect(inProgressItems.first()).toBeVisible()
// Initially both bars are at 0%
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*0%/)
// KSampler starts executing - node progress at 50%
exec.executing(jobId, KSAMPLER_NODE)
exec.progress(jobId, KSAMPLER_NODE, 5, 10)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*50%/)
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*50%/)
// Overall still 0% - no nodes completed yet
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
// KSampler finishes - overall advances (1 of 7 nodes)
exec.executed(jobId, KSAMPLER_NODE, {})
const oneNodePercent = Math.round((1 / ALL_NODE_IDS.length) * 100)
const pct = new RegExp(`width:\\s*${oneNodePercent}%`)
await expect(headerOverallProgress).toHaveAttribute('style', pct)
await expect(itemOverallProgress).toHaveAttribute('style', pct)
// Node progress reaches 100%
exec.progress(jobId, KSAMPLER_NODE, 10, 10)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*100%/)
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*100%/)
// Complete remaining nodes - overall reaches 100%
const remainingNodes = ALL_NODE_IDS.filter((id) => id !== KSAMPLER_NODE)
for (const nodeId of remainingNodes) {
exec.executing(jobId, nodeId)
exec.executed(jobId, nodeId, {})
}
await expect(headerOverallProgress).toHaveAttribute(
'style',
/width:\s*100%/
)
await expect(itemOverallProgress).toHaveAttribute('style', /width:\s*100%/)
})
})

View File

@@ -1,34 +1,19 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
// Constants
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
async function duplicateSubgraphNodeViaAltDrag(
comfyPage: ComfyPage
): Promise<void> {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const subgraphPos = await subgraphNode.getPosition()
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
try {
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
} finally {
await comfyPage.page.keyboard.up('Alt')
}
}
test.describe('Subgraph Unpacking', () => {
test('Unpacking subgraph with duplicate links does not create extra links', async ({
comfyPage
@@ -52,14 +37,18 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
if (!ksampler) return { error: 'No KSampler found after unpack' }
const linkedInputCount = ksampler.inputs.filter(
(input) => input.link != null
(i) => i.link != null
).length
return { linkCount, linkedInputCount, nodeCount: nodes.length }
})
expect(result).not.toHaveProperty('error')
// Should have exactly 1 link (EmptyLatentImage→KSampler)
// not 4 (with 3 duplicates). The KSampler→output link is dropped
// because the subgraph output has no downstream connection.
expect(result.linkCount).toBe(1)
// KSampler should have exactly 1 linked input (latent_image)
expect(result.linkedInputCount).toBe(1)
})
})
@@ -73,15 +62,14 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
const node = await comfyPage.nodeOps.getNodeRefById('5')
await node.convertToSubgraph()
await comfyPage.nextFrame()
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(1)
await expect.poll(() => comfyPage.subgraph.getNodeCount()).toBe(1)
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
expect(subgraphNodes.length).toBe(1)
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
expect(finalNodeCount).toBe(1)
})
test('Can delete subgraph node', async ({ comfyPage }) => {
@@ -94,47 +82,69 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.delete()
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
const deletedNode = await comfyPage.nodeOps.getNodeRefById('2')
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount - 1)
await expect.poll(() => deletedNode.exists()).toBe(false)
expect(await deletedNode.exists()).toBe(false)
})
test.describe('Subgraph Copy', () => {
test('Can duplicate a subgraph node by alt-dragging', async ({
test.describe('Subgraph copy and paste', () => {
test('Can copy subgraph node by dragging + alt', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await duplicateSubgraphNodeViaAltDrag(comfyPage)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(2)
})
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
test('Alt-dragging a subgraph node creates a new subgraph type', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await duplicateSubgraphNodeViaAltDrag(comfyPage)
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(2)
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect a second subgraph node to be created (2 total)
expect(subgraphNodes.length).toBe(2)
})
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes and expect all unique IDs
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect the second subgraph node to have a unique type
const nodeType1 = await subgraphNodes[0].getType()
const nodeType2 = await subgraphNodes[1].getType()
expect(nodeType1).not.toBe(nodeType2)

View File

@@ -2,91 +2,209 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/helpers/promotedWidgets'
import {
getPromotedWidgets,
getPseudoPreviewWidgets,
getNonPreviewPromotedWidgets
} from '@e2e/helpers/promotedWidgets'
const domPreviewSelector = '.image-preview'
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Deleting the promoted source removes the exterior DOM widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('5')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
test.describe(
'Subgraph Lifecycle Edge Behaviors',
{ tag: ['@subgraph'] },
() => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
await comfyPage.nextFrame()
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const hostNode = window.app!.canvas.graph!.getNodeById('11')
const proxyWidgets = hostNode?.properties?.proxyWidgets
return {
proxyWidgetCount: Array.isArray(proxyWidgets)
? proxyWidgets.length
: 0,
firstWidgetType: hostNode?.widgets?.[0]?.type
}
})
})
.toEqual({
proxyWidgetCount: 0,
firstWidgetType: undefined
})
})
test('Promoted widget disappears from DOM after interior node deletion', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
test('Removing the preview subgraph clears promoted preview state and DOM', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
).toBe(true)
})
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
test('Non-preview widgets coexist with pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
await subgraphNode.delete()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
comfyPage,
'5'
)
expect(await subgraphNode.exists()).toBe(false)
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
expect(
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
).toBe(true)
})
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.filter((n) => n.isSubgraphNode()).length
})
expect(subgraphNodeCount).toBe(0)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
})
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.delete()
expect(await subgraphNode.exists()).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
expect(firstNodeBefore.length).toBeGreaterThan(0)
expect(secondNodeBefore.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('7')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const firstNodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.graph!.getNodeById('7')
})
expect(firstNodeExists).toBe(false)
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
expect(secondNodeAfter).toEqual(secondNodeBefore)
})
})
})
})
}
)

View File

@@ -3,8 +3,15 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
// Constants
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
nodeSearchContainer: '.node-search-container'
} as const
function hasVisibleNodeInViewport() {
const canvas = window.app!.canvas
if (!canvas?.graph?._nodes?.length) return false
@@ -32,7 +39,15 @@ function hasVisibleNodeInViewport() {
}
test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
test.describe('Subgraph Navigation and UI', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('Breadcrumb and Workflow Context', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
@@ -47,12 +62,18 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
// Navigate into subgraph
await subgraphNode.navigateIntoSubgraph()
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 20000
})
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back and edit title
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
@@ -71,40 +92,59 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back into subgraph
await subgraphNode.navigateIntoSubgraph()
await expect(breadcrumb).toBeVisible()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
test('Switching workflows while inside subgraph returns to root graph context and hides the breadcrumb', async ({
test('Switching workflows while inside subgraph returns to root graph context', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
const backButton = breadcrumb.locator('.back-button')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(backButton).toBeVisible()
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.locator('.p-breadcrumb')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect(breadcrumb).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect(breadcrumb).toBeHidden()
})
})
@@ -117,6 +157,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Change the Exit Subgraph keybinding from Escape to Alt+Q
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
@@ -141,26 +182,28 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
}
])
// Reload the page
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await expect(
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
).toBeVisible()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(
await comfyPage.subgraph.isInSubgraph(),
'Escape should stay inside the subgraph after the default binding is unset'
).toBe(true)
if (!(await comfyPage.subgraph.isInSubgraph())) {
throw new Error('Not in subgraph')
}
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
@@ -174,36 +217,39 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await expect(
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
).toBeVisible()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
expect(
await comfyPage.subgraph.isInSubgraph(),
'Precondition failed: expected to be inside the subgraph before opening settings'
).toBe(true)
// Verify we're in a subgraph
if (!(await comfyPage.subgraph.isInSubgraph())) {
throw new Error('Not in subgraph')
}
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeVisible()
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
state: 'visible'
})
// Press Escape - should close dialog, not exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Dialog should be closed
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeHidden()
comfyPage.page.locator('[data-testid="settings-dialog"]')
).not.toBeVisible()
// Should still be in subgraph
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
test.describe('Subgraph viewport restoration', () => {
test.describe('Viewport', () => {
test('first visit fits viewport to subgraph nodes (LG)', async ({
comfyPage
}) => {
@@ -212,12 +258,24 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph!
const sgNode = graph._nodes.find((n) =>
'isSubgraphNode' in n
? (
n as unknown as { isSubgraphNode: () => boolean }
).isSubgraphNode()
: false
) as unknown as { subgraph?: typeof graph } | undefined
if (!sgNode?.subgraph) throw new Error('No subgraph node')
canvas.setGraph(sgNode.subgraph)
})
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2_000
timeout: 2000
})
.toBe(true)
})
@@ -235,7 +293,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2_000
timeout: 2000
})
.toBe(true)
})
@@ -266,7 +324,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2_000 }
{ timeout: 2000 }
)
.toEqual({
scale: expect.closeTo(rootViewport.scale, 2),
@@ -278,43 +336,61 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
})
})
test.describe('Subgraph progress clear on navigation', () => {
test.describe('Progress State', () => {
test('Stale progress is cleared on subgraph node after navigating back', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Find the subgraph node
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
// Simulate a stale progress value on the subgraph node.
// This happens when:
// 1. User views root graph during execution
// 2. Progress watcher sets node.progress = 0.5
// 3. User enters subgraph
// 4. Execution completes (nodeProgressStates becomes {})
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
// SubgraphNode isn't visible so it keeps stale progress
// 6. User navigates back — watcher should fire and clear it
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.5
}, subgraphNodeId)
// Verify progress is set
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId)
expect(progressBefore).toBe(0.5)
// Navigate into the subgraph
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect
.poll(
() =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId),
{ timeout: 2_000 }
)
.toBeUndefined()
// The progress watcher should fire when graph changes (because
// nodeLocationProgressStates is empty {} and the watcher should
// iterate canvas.graph.nodes to clear stale node.progress values).
//
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
// fire on subgraph->root navigation when progress is already empty,
// leaving stale node.progress = 0.5 on the SubgraphNode.
await expect(async () => {
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
@@ -342,23 +418,21 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(node) =>
typeof node.isSubgraphNode === 'function' &&
node.isSubgraphNode()
)
if (!subgraphNode) return { exists: false, progress: null }
await expect(async () => {
const subgraphProgressState = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
if (!subgraphNode) {
return { exists: false, progress: null }
}
return { exists: true, progress: subgraphNode.progress }
}),
{ timeout: 5_000 }
)
.toEqual({ exists: true, progress: undefined })
return { exists: true, progress: subgraphNode.progress }
})
expect(subgraphProgressState.exists).toBe(true)
expect(subgraphProgressState.progress).toBeUndefined()
}).toPass({ timeout: 5_000 })
})
})
})

View File

@@ -4,44 +4,177 @@ import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
test.describe('Nested subgraph configure order', () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('Loads and queues without nested promotion resolution failures', async ({
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
comfyPage
}) => {
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
const { warnings } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page,
['No link found', 'Failed to resolve legacy -1']
)
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
expect(warnings).toEqual([])
})
const response = await responsePromise
expect(warnings).toEqual([])
expect(response.ok()).toBe(true)
} finally {
dispose()
test('All three subgraph levels resolve promoted widgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const results = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const allGraphs = [graph, ...graph.subgraphs.values()]
return allGraphs.flatMap((g) =>
g._nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((hostNode) => {
const proxyWidgets = Array.isArray(
hostNode.properties?.proxyWidgets
)
? hostNode.properties.proxyWidgets
: []
const widgetEntries = proxyWidgets
.filter(
(e: unknown): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string'
)
.map(([interiorNodeId, widgetName]: [string, string]) => {
const sg = hostNode.isSubgraphNode()
? hostNode.subgraph
: null
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
return {
interiorNodeId,
widgetName,
resolved:
interiorNode !== null && interiorNode !== undefined
}
})
return {
hostNodeId: String(hostNode.id),
widgetEntries
}
})
)
})
expect(
results.length,
'Should have subgraph host nodes at multiple nesting levels'
).toBeGreaterThanOrEqual(2)
for (const { hostNodeId, widgetEntries } of results) {
expect(
widgetEntries.length,
`Host node ${hostNodeId} should have promoted widgets`
).toBeGreaterThan(0)
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
expect(widgetName).toBeTruthy()
expect(
resolved,
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
).toBe(true)
}
}
})
test('Prompt execution succeeds without 400 error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const response = await responsePromise
expect(response.status()).not.toBe(400)
})
})
/**
* Regression tests for nested subgraph promotion where multiple interior
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
* with a "text" widget).
*
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
* The outer subgraph (node 4) promotes through node 3 using identity
* disambiguation (optional sourceNodeId in the promotion entry).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
*/
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Inner subgraph node has both text widgets promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const nonPreview = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (
(innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[]
)
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string' &&
!entry[1].startsWith('$$')
)
.map(
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
)
})
comfyExpect(nonPreview).toEqual([
['1', 'text'],
['2', 'text']
])
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
@@ -77,9 +210,60 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyExpect(values).toContain('11111111111')
comfyExpect(values).toContain('22222222222')
})
test.describe('Promoted border styling in Vue mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Node 4 is the outer SubgraphNode at root level.
// Its widgets are not promoted further (no parent subgraph),
// so none of its widget wrappers should carry the promoted ring.
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
await comfyExpect(outerNode).toBeVisible()
const outerPromotedRings = outerNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await comfyExpect(outerPromotedRings).toHaveCount(0)
// Navigate into the outer subgraph (node 4) to reach node 3
await comfyPage.vueNodes.enterSubgraph('4')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 3 is the intermediate SubgraphNode whose "text" widgets
// are promoted up to the outer subgraph (node 4).
// Its widget wrappers should carry the promoted border ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
await comfyExpect(intermediateNode).toBeVisible()
const intermediatePromotedRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await comfyExpect(intermediatePromotedRings).toHaveCount(1)
})
})
}
)
/**
* Regression test for PR #10532:
* Packing all nodes inside a subgraph into a nested subgraph was causing
* the parent subgraph node's promoted widget values to go blank.
*
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
* at promotion time). After repointing, input._widget still pointed to
* removed node IDs, causing missing-node failures and blank values on the
* next checkState cycle.
*/
test.describe(
'Nested subgraph pack preserves promoted widget values',
{ tag: ['@widget'] },
@@ -100,6 +284,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeLocator).toBeVisible()
// 1. Verify initial promoted widget values via Vue node DOM
const widthWidget = nodeLocator
.getByLabel('width', { exact: true })
.first()
@@ -125,8 +310,10 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
await comfyExpect(textWidget).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
// 2. Pack all interior nodes into a nested subgraph
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
// 6. Re-enable Vue nodes and verify values are preserved
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
@@ -158,9 +345,87 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
await comfyExpect(textAfter).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
})
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Verify the host node is visible
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeLocator).toBeVisible()
// Pack all interior nodes into a nested subgraph
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
// Verify all proxyWidgets entries resolve
await comfyExpect(async () => {
const result = await comfyPage.page.evaluate((hostId) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(hostId)
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
) {
return { error: 'Host node not found or not a subgraph node' }
}
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
const entries = (proxyWidgets as unknown[])
.filter(
(e): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string' &&
!e[1].startsWith('$$')
)
.map(([nodeId, widgetName]) => {
const interiorNode = hostNode.subgraph.getNodeById(
Number(nodeId)
)
return {
nodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return { entries, count: entries.length }
}, HOST_NODE_ID)
expect(result).not.toHaveProperty('error')
const { entries, count } = result as {
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
count: number
}
expect(count).toBeGreaterThan(0)
for (const entry of entries) {
expect(
entry.resolved,
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
).toBe(true)
}
}).toPass({ timeout: 5000 })
})
}
)
/**
* Regression test for nested subgraph packing leaving stale proxyWidgets
* on the outer SubgraphNode.
*
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
* Only ["3","seed"] (KSampler) should remain.
*
* Stale entries render as "Disconnected" placeholder widgets (type "button").
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
*/
test.describe(
'Nested subgraph stale proxyWidgets',
{ tag: ['@widget'] },
@@ -182,9 +447,12 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
// Only the KSampler seed widget should be present — no stale
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
await comfyExpect(widgets).toHaveCount(1)
await comfyExpect(widgets.first()).toBeVisible()
// Verify the seed widget is present via its label
const seedWidget = outerNode.getByLabel('seed', { exact: true })
await comfyExpect(seedWidget).toBeVisible()
})

View File

@@ -2,19 +2,19 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe(
'Subgraph Internal Operations',
{ tag: ['@slow', '@subgraph'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('Subgraph Clipboard Operations', () => {
test('Can copy and paste nodes inside a subgraph', async ({
comfyPage
}) => {
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
@@ -22,43 +22,45 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const nodeId = await comfyPage.page.evaluate(() => {
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodes = window.app!.canvas.graph!.nodes
return nodes?.[0]?.id || null
})
expect(nodeId).not.toBeNull()
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
expect(nodesInSubgraph).not.toBeNull()
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
String(nodesInSubgraph)
)
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('ControlOrMeta+c')
await comfyPage.page.keyboard.press('Control+c')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('ControlOrMeta+v')
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount + 1)
})
})
test.describe('Subgraph History Operations', () => {
test('Can undo and redo operations inside a subgraph', async ({
comfyPage
}) => {
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Add a node
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.nextFrame()
// Get initial node count
const initialCount = await comfyPage.subgraph.getNodeCount()
// Undo
await comfyPage.keyboard.undo()
await comfyPage.nextFrame()
@@ -66,6 +68,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount - 1)
// Redo
await comfyPage.keyboard.redo()
await comfyPage.nextFrame()
@@ -73,5 +76,5 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount)
})
})
})
}
)

View File

@@ -47,20 +47,25 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select just the KSampler node (id 3) which has a "seed" widget
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// SubgraphNode should exist
expect(await subgraphNode.exists()).toBe(true)
// The KSampler has a "seed" widget which is in the recommended list.
// The promotion store should have at least the seed widget promoted.
const nodeId = String(subgraphNode.id)
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'seed')
// SubgraphNode should have widgets (promoted views)
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
})
test('Preview-capable nodes keep regular and pseudo-widget promotions when converted', async ({
test('CLIPTextEncode text widget is auto-promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
@@ -108,12 +113,29 @@ test.describe(
)
await comfyPage.nextFrame()
// The subgraph node (id 11) should have a text widget promoted
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
test('Multiple promoted widgets all render on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Visibility in Vue Mode', () => {
@@ -121,7 +143,7 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -129,26 +151,57 @@ test.describe(
)
await comfyPage.vueNodes.waitForNodes()
// SubgraphNode (id 11) should render with its body
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
// It should have the Enter Subgraph button
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
// The promoted text widget should render inside the node
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
await expect(nodeBody).toBeVisible()
// Widgets section should exist and have at least one widget
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
})
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
const widgets = nodeBody.locator('.lg-node-widgets > div')
const count = await widgets.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Reactivity', () => {
test('Promoted and interior widgets stay in sync across navigation', async ({
test('Value changes on promoted widget sync to interior widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -158,30 +211,87 @@ test.describe(
const testContent = 'promoted-value-sync-test'
// Type into the promoted textarea on the SubgraphNode
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Interior CLIPTextEncode textarea should have the same value
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
})
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
test('Value changes on interior widget sync to promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'interior-value-sync-test'
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Type into the interior CLIPTextEncode textarea
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await interiorTextarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
await expect(promotedTextarea).toHaveValue(testContent)
})
test('Value persists through repeated navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'persistence-through-navigation'
// Set value on promoted widget
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
// Navigate in and out multiple times
for (let i = 0; i < 3; i++) {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
}
})
})
@@ -190,7 +300,7 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can promote and un-promote a widget from inside a subgraph', async ({
test('Can promote a widget from inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -198,9 +308,10 @@ test.describe(
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get the KSampler node (id 1) inside the subgraph
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
await ksampler.click('title')
// Right-click on the KSampler's "steps" widget (index 2) to promote it
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
@@ -249,13 +360,12 @@ test.describe(
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
@@ -263,11 +373,12 @@ test.describe(
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
// Navigate back in and un-promote
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode2.navigateIntoSubgraph()
const ksampler2 = await comfyPage.nodeOps.getNodeRefById('1')
await ksampler2.click('title')
const stepsWidget2 = await ksampler2.getWidget(2)
const stepsWidget2 = await (
await comfyPage.nodeOps.getNodeRefById('1')
).getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
@@ -285,8 +396,10 @@ test.describe(
await unpromoteEntry.click()
await comfyPage.nextFrame()
// Navigate back to parent
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'), {
timeout: 5000
@@ -309,16 +422,26 @@ test.describe(
)
await comfyPage.vueNodes.waitForNodes()
// Navigate into the subgraph (node id 11)
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// The interior CLIPTextEncode node (id 10) should render a textarea
// widget in Vue mode. Right-click it to verify the contextmenu
// event propagates correctly (fix from PR #9840) and shows the
// ComfyUI context menu with "Promote Widget".
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(clipNode).toBeVisible()
// Select the node first so the context menu builds correctly
await comfyPage.vueNodes.selectNode('10')
await comfyPage.nextFrame()
// Dispatch a contextmenu event directly on the textarea. A normal
// right-click is intercepted by the z-999 canvas overlay, but the
// Vue WidgetTextarea.vue handler listens on @contextmenu.capture,
// so dispatching the event directly tests the fix from PR #9840.
const textarea = clipNode.locator('textarea')
await expect(textarea).toBeVisible()
await textarea.dispatchEvent('contextmenu', {
@@ -328,6 +451,8 @@ test.describe(
})
await comfyPage.nextFrame()
// The PrimeVue context menu should show "Promote Widget" since
// the node is inside a subgraph (not the root graph).
const promoteEntry = comfyPage.page
.locator('.p-contextmenu')
.locator('text=Promote Widget')
@@ -337,7 +462,7 @@ test.describe(
})
test.describe('Pseudo-Widget Promotion', () => {
test('Promoted preview nodes render custom content in Vue mode', async ({
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -381,6 +506,11 @@ test.describe(
test.describe('Vue Mode - Promoted Preview Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
@@ -389,35 +519,14 @@ test.describe(
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('filename_prefix')
expect(promotedNames.some((name) => name.startsWith('$$'))).toBe(true)
const loadImageNode = await comfyPage.nodeOps.getNodeRefById('11')
const loadImagePosition = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: loadImagePosition
})
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
// The node body should exist
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
await expect(nodeBody).toBeVisible()
await expect(
nodeBody.locator('.lg-node-widgets > div').first()
).toBeVisible()
await expect(nodeBody.locator('.image-preview img')).toHaveCount(1, {
timeout: 30_000
})
await expect(nodeBody.locator('.lg-node-widgets')).not.toContainText(
'$$canvas-image-preview'
)
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -425,6 +534,10 @@ test.describe(
)
await comfyPage.nextFrame()
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
@@ -440,12 +553,29 @@ test.describe(
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
let editedTextarea = false
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
@@ -458,11 +588,8 @@ test.describe(
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
}
}
expect(editedTextarea).toBe(true)
})
})

View File

@@ -1,170 +1,218 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '@e2e/helpers/promotedWidgets'
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
// Constants
const TEST_WIDGET_CONTENT = 'Test content that should persist'
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
await comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.rootGraph.nodes.find(
(candidate) => String(candidate.id) === targetNodeId
)
if (!node || !('subgraph' in node) || !node.subgraph) {
throw new Error(`Subgraph node ${targetNodeId} not found`)
}
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
domWidget: '.comfy-multiline-input'
} as const
window.app!.canvas.openSubgraph(node.subgraph, node)
}, nodeId)
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
}),
{ timeout: 5_000 }
)
.toBe(true)
}
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
test.describe(
'Subgraph Promoted Widget DOM',
{ tag: ['@slow', '@subgraph'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
await parentTextarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
await openSubgraphById(comfyPage, '11')
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe('DOM Widget Navigation and Persistence', () => {
test('DOM widget visibility persists through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
// Verify promoted widget is visible in parent graph
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
await subgraphNode.navigateIntoSubgraph()
// Verify widget is visible in subgraph
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
// Navigate back
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Verify widget is still visible
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
})
test('DOM widget content is preserved through navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
await textarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const parentCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(parentCount).toBeGreaterThan(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(subgraphCount).toBe(parentCount)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(parentCount)
})
})
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
test.describe('DOM Cleanup', () => {
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const initialCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(initialCount).toBe(1)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.delete()
await openSubgraphById(comfyPage, '11')
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(0)
})
await comfyPage.subgraph.removeSlot('input', 'text')
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
// Enable new menu for breadcrumb navigation
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.subgraph.exitViaBreadcrumb()
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
await comfyPage.workflow.loadWorkflow(workflowName)
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(0)
const textareaCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(textareaCount).toBe(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
// Navigate into subgraph (method now handles retries internally)
await subgraphNode.navigateIntoSubgraph()
await comfyPage.subgraph.removeSlot('input', 'text')
// Wait for breadcrumb to be visible
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 5000
})
// Click breadcrumb to navigate back to parent graph
const homeBreadcrumb = comfyPage.page.locator(
'.p-breadcrumb-list > :first-child'
)
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
// Check that the subgraph node has no widgets after removing the text slot
const widgetCount = await comfyPage.page.evaluate(() => {
return window.app!.canvas.graph!.nodes[0].widgets?.length || 0
})
expect(widgetCount).toBe(0)
})
})
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
test.describe('DOM Positioning', () => {
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
const parentCount = await comfyPage.page
.locator(VISIBLE_DOM_WIDGET_SELECTOR)
.count()
expect(parentCount).toBeGreaterThan(1)
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
const subgraphNodeId = String(subgraphNode.id)
await expect(async () => {
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
}).toPass({ timeout: 5000 })
await openSubgraphById(comfyPage, '11')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(parentCount)
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
await comfyPage.subgraph.exitViaBreadcrumb()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator
.getByLabel('seed', { exact: true })
.first()
await expect(seedWidget).toBeVisible()
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(parentCount)
// Verify widget is inside the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
})
})
})
}
)

View File

@@ -23,7 +23,7 @@ async function exitSubgraphAndPublish(
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5_000 })
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await comfyPage.toast.closeToasts(1)
}
@@ -36,7 +36,7 @@ async function searchAndExpectResult(
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.input.fill(searchTerm)
await expect(comfyPage.searchBox.findResult(expectedResult)).toBeVisible({
timeout: 10_000
timeout: 10000
})
}
@@ -49,22 +49,35 @@ test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
)
})
test('Can set search aliases on subgraph and find via search', async ({
comfyPage
}) => {
const subgraphNode = await createSubgraphAndNavigateInto(comfyPage)
await comfyPage.command.executeCommand('Comfy.Subgraph.SetSearchAliases', {
aliases: 'qwerty,unicorn'
})
const blueprintName = `test-aliases-${Date.now()}`
await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName)
await searchAndExpectResult(comfyPage, 'unicorn', blueprintName)
})
test('Can set description on subgraph', async ({ comfyPage }) => {
await createSubgraphAndNavigateInto(comfyPage)
await comfyPage.command.executeCommand('Comfy.Subgraph.SetDescription', {
description: 'This is a test description'
})
// Verify the description was set on the subgraph's extra
const description = await comfyPage.page.evaluate(() => {
const subgraph = window.app!.canvas.subgraph
const subgraph = window['app']!.canvas.subgraph
return (subgraph?.extra as Record<string, unknown>)?.BlueprintDescription
})
expect(description).toBe('This is a test description')
})
test('Published search aliases remain searchable after reload', async ({
test('Search aliases persist after publish and reload', async ({
comfyPage
}) => {
const subgraphNode = await createSubgraphAndNavigateInto(comfyPage)
@@ -76,9 +89,10 @@ test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
const blueprintName = `test-persist-${Date.now()}`
await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName)
// Reload the page to ensure aliases are persisted
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
() => window['app'] && window['app'].extensionManager
)
await comfyPage.nextFrame()

View File

@@ -1,143 +1,433 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
import type { PromotedWidgetEntry } from '@e2e/helpers/promotedWidgets'
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import {
getPromotedWidgets,
getPromotedWidgetNames,
getPromotedWidgetCount
} from '@e2e/helpers/promotedWidgets'
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const getPromotedHostWidgetValues = async (
comfyPage: ComfyPage,
nodeIds: string[]
) => {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
const expectPromotedWidgetsToResolveToInteriorNodes = async (
comfyPage: ComfyPage,
hostSubgraphNodeId: string,
widgets: PromotedWidgetEntry[]
) => {
const interiorNodeIds = widgets.map(([id]) => id)
const results = await comfyPage.page.evaluate(
([hostId, ids]) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(Number(hostId))
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
return interiorNode !== null && interiorNode !== undefined
})
}, nodeIds)
},
[hostSubgraphNodeId, interiorNodeIds] as const
)
for (const exists of results) {
expect(exists).toBe(true)
}
}
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
await expect(beforeReload).toBeVisible()
await comfyPage.subgraph.serializeAndReload()
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(afterReload).toHaveCount(1)
await expect(afterReload).toBeVisible()
})
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.some(([, widgetName]) => widgetName === 'batch_size')).toBe(
true
)
})
test('Duplicate ID remap workflow remains navigable after a full reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test.describe('Legacy prefixed proxyWidget normalization', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test.describe('Deterministic proxyWidgets Hydrate', () => {
test('proxyWidgets entries map to real interior node IDs after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
await expect(textarea).toBeDisabled()
})
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const workflowName =
'subgraphs/subgraph-multi-instance-promoted-text-values'
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const initialValues = await getPromotedHostWidgetValues(
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
hostNodeIds
'11',
widgets
)
expect(initialValues.map(({ values }) => values[0])).toEqual(
expectedValues
})
test('proxyWidgets entries survive double round-trip without drift', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
initialWidgets
)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
const afterFirst = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
hostNodeIds
'11',
afterFirst
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
await comfyPage.subgraph.serializeAndReload()
const afterSecond = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterSecond
)
expect(afterFirst).toEqual(initialWidgets)
expect(afterSecond).toEqual(initialWidgets)
})
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'2',
widgets
)
})
})
test.describe('Legacy And Round-Trip Coverage', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
).toBe(false)
expect(
promotedWidgets.some(
([interiorNodeId, widgetName]) =>
interiorNodeId !== '-1' && widgetName === 'batch_size'
)
).toBe(true)
})
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(beforePromoted).toContain('text')
await comfyPage.subgraph.serializeAndReload()
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(afterPromoted).toContain('text')
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(widgetCount).toBeGreaterThan(0)
})
test('Multi-link input representative stays stable through save/reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(beforeSnapshot.length).toBeGreaterThan(0)
await comfyPage.subgraph.serializeAndReload()
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(afterSnapshot).toEqual(beforeSnapshot)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
const originalPos = await originalNode.getPosition()
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
await comfyPage.nextFrame()
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
expect(subgraphNodeIds.length).toBeGreaterThan(1)
for (const nodeId of subgraphNodeIds) {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
}
})
})
test.describe('Duplicate ID Remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
// TODO: Extract allNodeIds accessor into LGraph
const allGraphs = [graph, ...graph.subgraphs.values()]
const allIds = allGraphs
.flatMap((g) => g._nodes)
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
return { allIds, uniqueCount: new Set(allIds).size }
})
expect(result.uniqueCount).toBe(result.allIds.length)
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
})
test('Root graph node IDs are preserved as canonical', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const rootIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
})
expect(rootIds).toEqual([1, 2, 5])
})
test('Promoted widget tuples are stable after full page reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const beforeSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(beforeSnapshot.length).toBeGreaterThan(0)
expect(
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
).toBe(true)
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
await expect(async () => {
const afterSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(afterSnapshot).toEqual(beforeSnapshot)
}).toPass({ timeout: 5_000 })
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const invalidLinks = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const labeledGraphs: [string, typeof graph][] = [
['root', graph],
...[...graph.subgraphs.entries()].map(
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
)
]
const isNonNegative = (id: number | string) =>
typeof id === 'number' && id >= 0
return labeledGraphs.flatMap(([label, g]) =>
[...g._links.values()].flatMap((link) =>
[
isNonNegative(link.origin_id) &&
!g._nodes_by_id[link.origin_id] &&
`${label}: origin_id ${link.origin_id} not found`,
isNonNegative(link.target_id) &&
!g._nodes_by_id[link.target_id] &&
`${label}: target_id ${link.target_id} not found`
].filter(Boolean)
)
)
})
expect(invalidLinks).toEqual([])
})
test('Subgraph navigation works after ID remapping', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
/**
* Regression test for legacy-prefixed proxyWidget normalization.
*
* Older serialized workflows stored proxyWidget entries with prefixed widget
* names like "6: 3: string_a" instead of plain "string_a". This caused
* resolution failures during configure, resulting in missing promoted widgets.
*
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
* the promoted widget should render with the clean name "string_a".
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
*/
test.describe(
'Legacy Prefixed proxyWidget Normalization',
{ tag: ['@subgraph', '@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
const { warnings } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page
)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
comfyExpect(warnings).toEqual([])
})
test('Promoted widget renders with normalized name, not legacy prefix', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
// The promoted widget should render with the clean name "string_a",
// not the legacy-prefixed "6: 3: string_a".
const promotedWidget = outerNode
.getByLabel('string_a', { exact: true })
.first()
await expect(promotedWidget).toBeVisible()
})
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
// Both widget rows should be valid "string_a" widgets — no stale
// "Disconnected" placeholders from unresolved legacy entries.
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
for (const row of await widgetRows.all()) {
await expect(
row.getByLabel('string_a', { exact: true })
).toBeVisible()
}
})
test('Promoted widget value is editable as a text input', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
})
}
)
})

View File

@@ -5,18 +5,20 @@ import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import {
expectSlotsWithinBounds,
measureNodeSlotOffsets
} from '@e2e/fixtures/utils/slotBoundsUtil'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
const RENAMED_SLOT_NAME = 'renamed_slot_name'
const RENAMED_NAME = 'renamed_slot_name'
const SECOND_RENAMED_NAME = 'second_renamed_name'
const RENAMED_LABEL = 'my_seed'
// Common selectors
const SELECTORS = {
promptDialog: '.graphdialog input'
} as const
@@ -30,7 +32,7 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
)
})
test.describe('I/O Slot Management', () => {
test.describe('I/O Slot CRUD', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -81,6 +83,8 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('input')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -99,6 +103,8 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('output')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -106,8 +112,10 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
.poll(() => comfyPage.subgraph.getSlotCount('output'))
.toBe(initialCount - 1)
})
})
test('Can rename an input slot from the context menu', async ({
test.describe('Slot Rename', () => {
test('Can rename I/O slots via right-click context menu', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -121,10 +129,13 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -228,17 +239,26 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
// Use direct pointer event approach to double-click on label
await comfyPage.page.evaluate(() => {
const app = window.app!
const graph = app.canvas.graph
if (!graph || !('inputNode' in graph))
if (!graph || !('inputNode' in graph)) {
throw new Error('Expected to be in subgraph')
}
const input = graph.inputs?.[0]
if (!input?.labelPos)
throw new Error('Could not get label position for testing')
if (!input?.labelPos) {
throw new Error('Could not get label position for testing')
}
// Use labelPos for more precise clicking on the text
const testX = input.labelPos[0]
const testY = input.labelPos[1]
// Create a minimal mock event with required properties
// Full PointerEvent creation is unnecessary for this test
const leftClickEvent = {
canvasX: input.labelPos[0],
canvasY: input.labelPos[1],
canvasX: testX,
canvasY: testY,
button: 0,
preventDefault: () => {},
stopPropagation: () => {}
@@ -252,6 +272,7 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
app.canvas.linkConnector
)
// Trigger double-click if pointer has the handler
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(leftClickEvent)
}
@@ -260,21 +281,25 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const labelClickRenamedName = 'label_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(labelClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
})
test.describe('Subgraph Slot Rename Dialog', () => {
test.describe('Slot Rename Dialog', () => {
test('Shows current slot label (not stale) in rename dialog', async ({
comfyPage
}) => {
@@ -283,27 +308,39 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get initial slot label
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
if (initialInputLabel === null) {
throw new Error(
'Expected subgraph to have an input slot label for rightClickInputSlot'
)
}
// First rename
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Clear and enter new name
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Verify the rename worked
const afterFirstRename = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph))
@@ -315,29 +352,42 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
displayName: slot?.displayName || slot?.label || slot?.name || null
}
})
expect(afterFirstRename.label).toBe(RENAMED_SLOT_NAME)
expect(afterFirstRename.label).toBe(RENAMED_NAME)
// Now rename again - this is where the bug would show
// We need to use the index-based approach since the method looks for slot.name
await comfyPage.subgraph.rightClickInputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(
SELECTORS.promptDialog
)
expect(dialogValue).toBe(RENAMED_SLOT_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name)
// This should show the current label (RENAMED_NAME), not the original name
expect(dialogValue).toBe(RENAMED_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
// Complete the second rename to ensure everything still works
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Verify the second rename worked
const afterSecondRename = await comfyPage.subgraph.getSlotLabel('input')
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
})
@@ -350,193 +400,173 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get initial output slot label
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
if (initialOutputLabel === null) {
throw new Error(
'Expected subgraph to have an output slot label for rightClickOutputSlot'
)
}
// First rename
await comfyPage.subgraph.rightClickOutputSlot(initialOutputLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Clear and enter new name
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Now rename again to check for stale content
// We need to use the index-based approach since the method looks for slot.name
await comfyPage.subgraph.rightClickOutputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(
SELECTORS.promptDialog
)
expect(dialogValue).toBe(RENAMED_SLOT_NAME)
// This should show the current label (RENAMED_NAME), not the original name
expect(dialogValue).toBe(RENAMED_NAME)
})
})
test.describe('Subgraph input slot rename propagation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test.describe('Slot Rename Propagation', () => {
/**
* Regression test for subgraph input slot rename propagation.
*
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
* update the promoted widget label shown on the parent SubgraphNode and
* keep the widget positioned in the node body (not the header).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
*/
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/test-values-input-subgraph'
)
const { page } = comfyPage
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const subgraphNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(subgraphNode).toBeVisible()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await comfyExpect(sgNode).toBeVisible()
const seedWidget = subgraphNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await comfyExpect(seedWidget).toBeVisible()
// Verify widget is in the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(sgNode, seedWidget)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
// litegraph context menu, so temporarily disable Vue nodes.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await subgraphNodeRef.navigateIntoSubgraph()
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await sgNodeRef.navigateIntoSubgraph()
const seedSlotName = await comfyPage.page.evaluate(() => {
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph) return null
const inputs = (graph as { inputs?: Array<{ name: string }> }).inputs
return (
inputs?.find((input) => input.name.includes('seed'))?.name ?? null
)
const inputs = (
graph as { inputs?: Array<{ name: string; type: string }> }
).inputs
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
})
expect(seedSlotName).not.toBeNull()
// 4. Right-click the seed input slot and rename it
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_LABEL)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
const dialog = SELECTORS.promptDialog
await page.waitForSelector(dialog, { state: 'visible' })
await page.fill(dialog, '')
await page.fill(dialog, RENAMED_LABEL)
await page.keyboard.press('Enter')
await page.waitForSelector(dialog, { state: 'hidden' })
// 5. Navigate back to parent graph and re-enable Vue nodes
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const subgraphNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(subgraphNodeAfter).toBeVisible()
// 6. Verify the widget label updated to the renamed value
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await comfyExpect(sgNodeAfter).toBeVisible()
const updatedLabel = await comfyPage.page.evaluate(() => {
const updatedLabel = await page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const widget = node.widgets?.find((entry: { name: string }) =>
entry.name.includes('seed')
const w = node.widgets?.find((w: { name: string }) =>
w.name.includes('seed')
)
return widget?.label || widget?.name || null
return w?.label || w?.name || null
})
expect(updatedLabel).toBe(RENAMED_LABEL)
const seedWidgetAfter = subgraphNodeAfter.getByLabel('seed', {
exact: true
})
await expect(seedWidgetAfter).toBeVisible()
await expect(
subgraphNodeAfter.getByText(RENAMED_LABEL, { exact: true })
).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(
subgraphNodeAfter,
seedWidgetAfter
)
// 7. Verify the widget is still in the body, not the header
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await comfyExpect(seedWidgetAfter).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
})
})
test.describe('Subgraph promoted widget-input slot position', () => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
test.describe('Compressed target_slot', () => {
test('Can create widget from link with compressed target_slot', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
'subgraphs/subgraph-compressed-target-slot'
)
// Two frames needed: first renders slot changes, second stabilizes layout
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Two frames needed: first renders slot changes, second stabilizes layout
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const before = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(input: { type: string }) => input.type === 'STRING'
)
return textInput?.label || textInput?.name || null
const step = await comfyPage.page.evaluate(() => {
return window.app!.graph!.nodes[0].widgets![0].options.step
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
await comfyPage.subgraph.exitViaBreadcrumb()
const after = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
expect(after!.widgetName).toBe(before!.widgetName)
expect(step).toBe(10)
})
})
test.describe('Subgraph slot alignment after LG layout scale', () => {
test.describe('Slot Alignment', () => {
/**
* Regression test for link misalignment on SubgraphNodes when loading
* workflows with workflowRendererVersion: "LG".
*
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
* slot offsets. The fix uses DOM-relative measurement instead.
*/
test('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
@@ -580,4 +610,91 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
}
})
})
test.describe('Promoted Slot Position', () => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = SELECTORS.promptDialog
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
})
})

View File

@@ -1,66 +0,0 @@
---
globs:
- 'src/components/**/*.vue'
- 'src/views/**/*.vue'
---
# Comfy Design Standards
Applies when implementing or modifying user-facing components and views.
## Before Implementing UI Changes
Consult the **Comfy Design Standards** Figma file to ensure your changes follow the agreed-upon design principles. Use the Figma MCP tool to fetch the current standards:
```javascript
get_figma_data({ fileKey: 'QreIv5htUaSICNuO2VBHw0', nodeId: '0:1' })
```
The Figma file is the single source of truth. Always fetch it live — do not rely on cached assumptions.
> **Note:** The Figma MCP is read-only. It cannot detect changes or diffs between versions. Always fetch the latest state before implementing.
### Key Sections
| Section | Node ID | When to consult |
| -------------------- | --------- | ----------------------------------------------------------------------------- |
| Hover States | `1:2` | Adding/modifying interactive elements (buttons, inputs, links, nav items) |
| Click Targets | `4:243` | Adding clickable elements, especially small ones (icons, handles, connectors) |
| Affordances | `15:2202` | Any interactive element — ensuring visual feedback on interaction |
| Feedback | `15:2334` | User actions that need confirmation, success/error states |
| Slips and How to Fix | `15:2337` | Error prevention, undo patterns, destructive actions |
| Design Pillars | `15:2340` | New features, architectural UI decisions |
| The User | `16:2348` | User flows, onboarding, accessibility |
Fetch the specific section relevant to your task:
```javascript
get_figma_data({ fileKey: 'QreIv5htUaSICNuO2VBHw0', nodeId: '<node-id>' })
```
## Figma Component Reference
The Figma file contains component specifications. When implementing these components, fetch details to match the design:
| Component | Component Set ID |
| ----------------- | ---------------- |
| Button/Default | `4:314` |
| Search | `4:2366` |
| Base Node Example | `4:4739` |
## Figma Token Translation Rules
When translating Figma design tokens into code:
- **Skip `-hover` and `-selected` suffixed tokens.** These states exist in Figma only for prototype demonstrations. On the frontend, hover and selected states must be derived programmatically (e.g., via `color-mix()` or Tailwind modifier classes like `hover:`).
- **Color tier system:** Figma uses a tiered color hierarchy:
- **Base** — default surface/background colors
- **Secondary** — elevated surfaces (e.g., sidebars, cards)
- **Tertiary** — elements on modal panels (one shade lighter than base)
- Map Figma token names directly to Tailwind 4 semantic tokens — never hardcode hex values.
## Integration with Codebase
- Map Figma color values to Tailwind 4 semantic tokens — never hardcode hex values
- Use `cn()` from `@/utils/tailwindUtil` for conditional class merging
- Use the `dark:` avoidance rule from AGENTS.md — semantic tokens handle both themes

View File

@@ -1,7 +1,6 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
treatConfigHintsAsErrors: true,
workspaces: {
'.': {
entry: [
@@ -34,9 +33,11 @@ const config: KnipConfig = {
'src/pages/**/*.astro',
'src/layouts/**/*.astro',
'src/components/**/*.vue',
'src/styles/global.css'
'src/styles/global.css',
'astro.config.ts'
],
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}']
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
}
},
ignoreBinaries: ['python3'],
@@ -53,6 +54,8 @@ const config: KnipConfig = {
// Auto generated API types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/ingest-types/src/zod.gen.ts',
// Used by stacked PR (feat/glsl-live-preview)
'src/renderer/glsl/useGLSLRenderer.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR

View File

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

627
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.59.0
eslint-plugin-oxlint: 1.55.0
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.3.0
@@ -89,14 +89,14 @@ catalog:
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^6.3.1
knip: ^6.0.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.6.1
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -1,8 +1,5 @@
/* eslint-disable testing-library/no-container */
/* eslint-disable testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
@@ -11,6 +8,8 @@ import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import type {
JobListItem,
JobStatus
@@ -115,9 +114,8 @@ function createWrapper({
}
})
const user = userEvent.setup()
const renderOptions: Record<string, unknown> = {
return mount(TopMenuSection, {
attachTo,
global: {
plugins: [pinia, i18n],
stubs: {
@@ -130,8 +128,7 @@ function createWrapper({
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template:
'<div data-testid="context-menu" :data-model="JSON.stringify(model)" />'
template: '<div />'
},
...stubs
},
@@ -139,23 +136,15 @@ function createWrapper({
tooltip: () => {}
}
}
}
if (attachTo) {
renderOptions.container = attachTo.appendChild(
document.createElement('div')
)
}
const { container, unmount } = render(TopMenuSection, renderOptions)
return { container, unmount, user }
})
}
function getLegacyCommandsContainer(container: Element): HTMLElement {
const legacyContainer = container.querySelector(
function getLegacyCommandsContainer(
wrapper: ReturnType<typeof createWrapper>
): HTMLElement {
const legacyContainer = wrapper.find(
'[data-testid="legacy-topbar-container"]'
)
).element
if (!(legacyContainer instanceof HTMLElement)) {
throw new Error('Expected legacy commands container to be present')
}
@@ -212,11 +201,9 @@ describe('TopMenuSection', () => {
})
it('should display CurrentUserButton and not display LoginButton', () => {
const { container } = createLegacyTabBarWrapper()
expect(
container.querySelector('current-user-button-stub')
).not.toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})
})
@@ -228,24 +215,24 @@ describe('TopMenuSection', () => {
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('login-button-stub')).not.toBeNull()
expect(container.querySelector('current-user-button-stub')).toBeNull()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
})
})
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('current-user-button-stub')).toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})
})
})
})
it('shows the active jobs label with the current count', async () => {
createWrapper()
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
queueStore.runningTasks = [
@@ -255,15 +242,19 @@ describe('TopMenuSection', () => {
await nextTick()
const queueButton = screen.getByTestId('queue-overlay-toggle')
expect(queueButton.textContent).toContain('3 active')
expect(screen.getByTestId('active-jobs-indicator')).toBeTruthy()
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
true
)
})
it('hides the active jobs indicator when no jobs are active', () => {
createWrapper()
const wrapper = createWrapper()
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
false
)
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
@@ -272,12 +263,16 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const { container } = createWrapper({ pinia })
const wrapper = createWrapper({ pinia })
await nextTick()
expect(screen.getByTestId('queue-overlay-toggle')).toBeTruthy()
expect(container.querySelector('queue-progress-overlay-stub')).toBeNull()
expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
true
)
expect(
wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
).toBe(false)
})
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
@@ -286,10 +281,10 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const { user } = createWrapper({ pinia })
const wrapper = createWrapper({ pinia })
const commandStore = useCommandStore(pinia)
await user.click(screen.getByTestId('queue-overlay-toggle'))
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.Queue.ToggleOverlay'
@@ -302,10 +297,10 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const { user } = createWrapper({ pinia })
const wrapper = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
await user.click(screen.getByTestId('queue-overlay-toggle'))
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
})
@@ -316,14 +311,14 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const { user } = createWrapper({ pinia })
const wrapper = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = screen.getByTestId('queue-overlay-toggle')
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
await user.click(toggleButton)
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
await user.click(toggleButton)
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
@@ -346,39 +341,39 @@ describe('TopMenuSection', () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const { container } = createWrapper({ pinia })
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-inline-progress-summary-stub')
).not.toBeNull()
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(true)
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const { container } = createWrapper({ pinia })
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-inline-progress-summary-stub')
).toBeNull()
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('does not render inline progress summary when run progress bar is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true, false)
const { container } = createWrapper({ pinia })
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-inline-progress-summary-stub')
).toBeNull()
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
@@ -392,7 +387,7 @@ describe('TopMenuSection', () => {
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const { unmount } = createWrapper({
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
@@ -406,7 +401,7 @@ describe('TopMenuSection', () => {
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
unmount()
wrapper.unmount()
actionbarTarget.remove()
}
})
@@ -429,36 +424,36 @@ describe('TopMenuSection', () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const { container } = createWrapper({ pinia })
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
).toBe(true)
})
it('renders queue notification banners when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const { container } = createWrapper({ pinia })
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
).toBe(true)
})
it('renders inline summary above banners when both are visible', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const { container } = createWrapper({ pinia })
const wrapper = createWrapper({ pinia })
await nextTick()
const html = container.innerHTML
const html = wrapper.html()
const inlineSummaryIndex = html.indexOf(
'queue-inline-progress-summary-stub'
)
@@ -482,7 +477,7 @@ describe('TopMenuSection', () => {
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const { container, unmount } = createWrapper({
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
@@ -498,49 +493,47 @@ describe('TopMenuSection', () => {
actionbarTarget.querySelector('queue-notification-banner-host-stub')
).toBeNull()
expect(
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
wrapper
.findComponent({ name: 'QueueNotificationBannerHost' })
.exists()
).toBe(true)
} finally {
unmount()
wrapper.unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const { container } = createWrapper()
const menuEl = container.querySelector('[data-testid="context-menu"]')
const model = JSON.parse(
menuEl?.getAttribute('data-model') ?? '[]'
) as MenuItem[]
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.label).toBe('Clear queue')
expect(model[0]?.disabled).toBe(true)
})
it('enables the clear queue context menu item when queued jobs exist', async () => {
const { container } = createWrapper()
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
await nextTick()
const menuEl = container.querySelector('[data-testid="context-menu"]')
const model = JSON.parse(
menuEl?.getAttribute('data-model') ?? '[]'
) as MenuItem[]
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
it('shows manager red dot only for manager conflicts', async () => {
const { container } = createWrapper()
const wrapper = createWrapper()
// Release red dot is mocked as true globally for this test file.
expect(container.querySelector('span.bg-red-500')).toBeNull()
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
mockData.setShowConflictRedDot(true)
await nextTick()
expect(container.querySelector('span.bg-red-500')).not.toBeNull()
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
})
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
@@ -562,19 +555,15 @@ describe('TopMenuSection', () => {
return undefined
})
const { container, unmount } = createWrapper({
pinia,
attachTo: document.body
})
const wrapper = createWrapper({ pinia, attachTo: document.body })
try {
await nextTick()
const actionbarContainer = container.querySelector('.actionbar-container')
expect(actionbarContainer).not.toBeNull()
expect(actionbarContainer!.classList).toContain('w-0')
const actionbarContainer = wrapper.find('.actionbar-container')
expect(actionbarContainer.classes()).toContain('w-0')
const legacyContainer = getLegacyCommandsContainer(container)
const legacyContainer = getLegacyCommandsContainer(wrapper)
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
if (rafCallbacks.length > 0) {
@@ -605,9 +594,9 @@ describe('TopMenuSection', () => {
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer!.classList).toContain('px-2')
expect(actionbarContainer.classes()).toContain('px-2')
} finally {
unmount()
wrapper.unmount()
vi.unstubAllGlobals()
}
})

View File

@@ -1,7 +1,9 @@
import { render, screen } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
// Mock ShortcutsList component
@@ -10,7 +12,7 @@ vi.mock('@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue', () => ({
name: 'ShortcutsList',
props: ['commands', 'subcategories', 'columns'],
template:
'<div data-testid="shortcuts-list">{{ JSON.stringify(subcategories) }}</div>'
'<div class="shortcuts-list-mock">{{ commands.length }} commands</div>'
}
}))
@@ -54,34 +56,25 @@ describe('EssentialsPanel', () => {
setActivePinia(createPinia())
})
it('should render ShortcutsList with essentials commands', async () => {
const { default: EssentialsPanel } =
await import('@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue')
render(EssentialsPanel)
it('should render ShortcutsList with essentials commands', () => {
const wrapper = mount(EssentialsPanel)
expect(screen.getByTestId('shortcuts-list')).toBeTruthy()
const shortcutsList = wrapper.findComponent(ShortcutsList)
expect(shortcutsList.exists()).toBe(true)
})
it('should categorize commands into subcategories', async () => {
const { default: EssentialsPanel } =
await import('@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue')
render(EssentialsPanel)
it('should categorize commands into subcategories', () => {
const wrapper = mount(EssentialsPanel)
const el = screen.getByTestId('shortcuts-list')
const subcategories = JSON.parse(el.textContent ?? '{}')
const shortcutsList = wrapper.findComponent(ShortcutsList)
const subcategories = shortcutsList.props('subcategories')
expect(subcategories).toHaveProperty('workflow')
expect(subcategories).toHaveProperty('node')
expect(subcategories).toHaveProperty('queue')
expect(subcategories.workflow).toContainEqual(
expect.objectContaining({ id: 'Workflow.New' })
)
expect(subcategories.node).toContainEqual(
expect.objectContaining({ id: 'Node.Add' })
)
expect(subcategories.queue).toContainEqual(
expect.objectContaining({ id: 'Queue.Clear' })
)
expect(subcategories.workflow).toContain(mockCommands[0])
expect(subcategories.node).toContain(mockCommands[1])
expect(subcategories.queue).toContain(mockCommands[2])
})
})

View File

@@ -1,7 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/vue'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
@@ -65,31 +64,36 @@ describe('ShortcutsList', () => {
}
it('should render shortcuts organized by subcategories', () => {
render(ShortcutsList, {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
expect(screen.getByText('Workflow')).toBeInTheDocument()
expect(screen.getByText('Node')).toBeInTheDocument()
expect(screen.getByText('Queue')).toBeInTheDocument()
expect(screen.getByText('New Blank Workflow')).toBeInTheDocument()
// Check that subcategories are rendered
expect(wrapper.text()).toContain('Workflow')
expect(wrapper.text()).toContain('Node')
expect(wrapper.text()).toContain('Queue')
// Check that commands are rendered
expect(wrapper.text()).toContain('New Blank Workflow')
})
it('should format keyboard shortcuts correctly', () => {
const { container } = render(ShortcutsList, {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
const text = container.textContent!
expect(text).toContain('Ctrl')
expect(text).toContain('n')
expect(text).toContain('Shift')
expect(text).toContain('a')
expect(text).toContain('c')
// Check for formatted keys
expect(wrapper.text()).toContain('Ctrl')
expect(wrapper.text()).toContain('n')
expect(wrapper.text()).toContain('Shift')
expect(wrapper.text()).toContain('a')
expect(wrapper.text()).toContain('c')
})
it('should filter out commands without keybindings', () => {
@@ -103,8 +107,9 @@ describe('ShortcutsList', () => {
} as ComfyCommandImpl
]
render(ShortcutsList, {
const wrapper = mount(ShortcutsList, {
props: {
commands: commandsWithoutKeybinding,
subcategories: {
...mockSubcategories,
other: [commandsWithoutKeybinding[3]]
@@ -112,7 +117,7 @@ describe('ShortcutsList', () => {
}
})
expect(screen.queryByText('No Keybinding')).not.toBeInTheDocument()
expect(wrapper.text()).not.toContain('No Keybinding')
})
it('should handle special key formatting', () => {
@@ -127,15 +132,16 @@ describe('ShortcutsList', () => {
}
} as ComfyCommandImpl
const { container } = render(ShortcutsList, {
const wrapper = mount(ShortcutsList, {
props: {
commands: [specialKeyCommand],
subcategories: {
special: [specialKeyCommand]
}
}
})
const text = container.textContent!
const text = wrapper.text()
expect(text).toContain('Cmd') // Meta -> Cmd
expect(text).toContain('↑') // ArrowUp -> ↑
expect(text).toContain('↵') // Enter -> ↵
@@ -144,14 +150,15 @@ describe('ShortcutsList', () => {
})
it('should use fallback subcategory titles', () => {
render(ShortcutsList, {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: {
unknown: [mockCommands[0]]
}
}
})
expect(screen.getByText('unknown')).toBeInTheDocument()
expect(wrapper.text()).toContain('unknown')
})
})

View File

@@ -1,9 +1,8 @@
/* eslint-disable testing-library/no-node-access */
/* eslint-disable testing-library/prefer-user-event */
import { createTestingPinia } from '@pinia/testing'
import { fireEvent, render, screen } from '@testing-library/vue'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import type { Mock } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -68,10 +67,9 @@ vi.mock('@/platform/distribution/types', () => ({
}))
// Mock clipboard API
const mockWriteText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: mockWriteText
writeText: vi.fn().mockResolvedValue(undefined)
},
configurable: true
})
@@ -89,9 +87,8 @@ const i18n = createI18n({
}
})
function renderBaseTerminal(props: Record<string, unknown> = {}) {
return render(BaseTerminal, {
props,
const mountBaseTerminal = () => {
return mount(BaseTerminal, {
global: {
plugins: [
createTestingPinia({
@@ -110,60 +107,68 @@ function renderBaseTerminal(props: Record<string, unknown> = {}) {
}
describe('BaseTerminal', () => {
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
beforeEach(() => {
vi.clearAllMocks()
})
it('emits created event on mount', () => {
const onCreated = vi.fn()
renderBaseTerminal({ onCreated })
afterEach(() => {
wrapper?.unmount()
})
expect(onCreated).toHaveBeenCalled()
expect(onCreated.mock.calls[0]).toHaveLength(2)
it('emits created event on mount', () => {
wrapper = mountBaseTerminal()
expect(wrapper.emitted('created')).toBeTruthy()
expect(wrapper.emitted('created')![0]).toHaveLength(2)
})
it('emits unmounted event on unmount', () => {
const onUnmounted = vi.fn()
const { unmount } = renderBaseTerminal({ onUnmounted })
unmount()
wrapper = mountBaseTerminal()
wrapper.unmount()
expect(onUnmounted).toHaveBeenCalled()
expect(wrapper.emitted('unmounted')).toBeTruthy()
})
it('button exists and has correct initial state', () => {
renderBaseTerminal()
it('button exists and has correct initial state', async () => {
wrapper = mountBaseTerminal()
const button = screen.getByRole('button')
expect(button).toHaveClass('opacity-0', 'pointer-events-none')
const button = wrapper.find('button[aria-label]')
expect(button.exists()).toBe(true)
expect(button.classes()).toContain('opacity-0')
expect(button.classes()).toContain('pointer-events-none')
})
it('shows correct tooltip when no selection', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
const { container } = renderBaseTerminal()
wrapper = mountBaseTerminal()
await fireEvent.mouseEnter(container.firstElementChild!)
await wrapper.trigger('mouseenter')
await nextTick()
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-label', 'Copy all')
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy all')
})
it('shows correct tooltip when selection exists', async () => {
mockTerminal.hasSelection.mockReturnValue(true)
const { container } = renderBaseTerminal()
wrapper = mountBaseTerminal()
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
const selectionCallback = mockCalls[0][0] as () => void
selectionCallback()
await nextTick()
await fireEvent.mouseEnter(container.firstElementChild!)
await wrapper.trigger('mouseenter')
await nextTick()
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-label', 'Copy selection')
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy selection')
})
it('copies selected text when selection exists', async () => {
@@ -171,17 +176,16 @@ describe('BaseTerminal', () => {
mockTerminal.hasSelection.mockReturnValue(true)
mockTerminal.getSelection.mockReturnValue(selectedText)
const { container } = renderBaseTerminal()
wrapper = mountBaseTerminal()
await fireEvent.mouseEnter(container.firstElementChild!)
await wrapper.trigger('mouseenter')
await nextTick()
const button = screen.getByRole('button')
await fireEvent.click(button)
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).not.toHaveBeenCalled()
expect(mockWriteText).toHaveBeenCalledWith(selectedText)
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText)
expect(mockTerminal.clearSelection).not.toHaveBeenCalled()
})
@@ -192,17 +196,16 @@ describe('BaseTerminal', () => {
.mockReturnValueOnce('') // First call returns empty (no selection)
.mockReturnValueOnce(allText) // Second call after selectAll returns all text
const { container } = renderBaseTerminal()
wrapper = mountBaseTerminal()
await fireEvent.mouseEnter(container.firstElementChild!)
await wrapper.trigger('mouseenter')
await nextTick()
const button = screen.getByRole('button')
await fireEvent.click(button)
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(mockWriteText).toHaveBeenCalledWith(allText)
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText)
expect(mockTerminal.clearSelection).toHaveBeenCalled()
})
@@ -210,16 +213,15 @@ describe('BaseTerminal', () => {
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection.mockReturnValue('')
const { container } = renderBaseTerminal()
wrapper = mountBaseTerminal()
await fireEvent.mouseEnter(container.firstElementChild!)
await wrapper.trigger('mouseenter')
await nextTick()
const button = screen.getByRole('button')
await fireEvent.click(button)
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(mockWriteText).not.toHaveBeenCalled()
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
})
})

View File

@@ -1,44 +1,44 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import Badge from './Badge.vue'
import { badgeVariants } from './badge.variants'
describe('Badge', () => {
it('renders label text', () => {
render(Badge, { props: { label: 'NEW' } })
expect(screen.getByText('NEW')).toBeInTheDocument()
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.text()).toBe('NEW')
})
it('renders numeric label', () => {
render(Badge, { props: { label: 5 } })
expect(screen.getByText('5')).toBeInTheDocument()
const wrapper = mount(Badge, { props: { label: 5 } })
expect(wrapper.text()).toBe('5')
})
it('defaults to dot variant when no label is provided', () => {
const { container } = render(Badge)
// eslint-disable-next-line testing-library/no-node-access -- dot badge has no text/role to query
expect(container.firstElementChild).toHaveClass('size-2')
const wrapper = mount(Badge)
expect(wrapper.classes()).toContain('size-2')
})
it('defaults to label variant when label is provided', () => {
render(Badge, { props: { label: 'NEW' } })
const el = screen.getByText('NEW')
expect(el).toHaveClass('font-semibold')
expect(el).toHaveClass('uppercase')
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.classes()).toContain('font-semibold')
expect(wrapper.classes()).toContain('uppercase')
})
it('applies circle variant', () => {
render(Badge, { props: { label: '3', variant: 'circle' } })
expect(screen.getByText('3')).toHaveClass('size-3.5')
const wrapper = mount(Badge, {
props: { label: '3', variant: 'circle' }
})
expect(wrapper.classes()).toContain('size-3.5')
})
it('merges custom class via cn()', () => {
render(Badge, { props: { label: 'Test', class: 'ml-2' } })
const el = screen.getByText('Test')
expect(el).toHaveClass('ml-2')
expect(el).toHaveClass('rounded-full')
const wrapper = mount(Badge, {
props: { label: 'Test', class: 'ml-2' }
})
expect(wrapper.classes()).toContain('ml-2')
expect(wrapper.classes()).toContain('rounded-full')
})
describe('twMerge preserves color alongside text-3xs font size', () => {
@@ -58,10 +58,12 @@ describe('Badge', () => {
)
it('cn() does not clobber text-white when merging with text-3xs', () => {
render(Badge, { props: { label: 'Test', severity: 'danger' } })
const el = screen.getByText('Test')
expect(el).toHaveClass('text-white')
expect(el).toHaveClass('text-3xs')
const wrapper = mount(Badge, {
props: { label: 'Test', severity: 'danger' }
})
const classList = wrapper.classes()
expect(classList).toContain('text-white')
expect(classList).toContain('text-3xs')
})
})
})

View File

@@ -1,22 +1,22 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import MarqueeLine from './MarqueeLine.vue'
describe(MarqueeLine, () => {
it('renders slot content', () => {
render(MarqueeLine, {
const wrapper = mount(MarqueeLine, {
slots: { default: 'Hello World' }
})
expect(screen.getByText('Hello World')).toBeInTheDocument()
expect(wrapper.text()).toBe('Hello World')
})
it('renders content inside a span within the container', () => {
render(MarqueeLine, {
const wrapper = mount(MarqueeLine, {
slots: { default: 'Test Text' }
})
const el = screen.getByText('Test Text')
expect(el.tagName).toBe('SPAN')
const span = wrapper.find('span')
expect(span.exists()).toBe(true)
expect(span.text()).toBe('Test Text')
})
})

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import type { ComponentProps } from 'vue-component-type-helpers'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import NotificationPopup from './NotificationPopup.vue'
@@ -13,11 +13,13 @@ const i18n = createI18n({
}
})
function renderPopup(
props: { title: string; [key: string]: unknown } = { title: 'Test' },
function mountPopup(
props: ComponentProps<typeof NotificationPopup> = {
title: 'Test'
},
slots: Record<string, string> = {}
) {
return render(NotificationPopup, {
return mount(NotificationPopup, {
global: { plugins: [i18n] },
props,
slots
@@ -26,58 +28,51 @@ function renderPopup(
describe('NotificationPopup', () => {
it('renders title', () => {
renderPopup({ title: 'Hello World' })
expect(screen.getByRole('status')).toHaveTextContent('Hello World')
const wrapper = mountPopup({ title: 'Hello World' })
expect(wrapper.text()).toContain('Hello World')
})
it('has role="status" for accessibility', () => {
renderPopup()
expect(screen.getByRole('status')).toBeInTheDocument()
const wrapper = mountPopup()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('renders subtitle when provided', () => {
renderPopup({ title: 'T', subtitle: 'v1.2.3' })
expect(screen.getByRole('status')).toHaveTextContent('v1.2.3')
const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' })
expect(wrapper.text()).toContain('v1.2.3')
})
it('renders icon when provided', () => {
const { container } = renderPopup({
const wrapper = mountPopup({
title: 'T',
icon: 'icon-[lucide--rocket]'
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const icon = container.querySelector('i.icon-\\[lucide--rocket\\]')
expect(icon).toBeInTheDocument()
expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true)
})
it('emits close when close button clicked', async () => {
const user = userEvent.setup()
const closeSpy = vi.fn()
renderPopup({ title: 'T', showClose: true, onClose: closeSpy })
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(closeSpy).toHaveBeenCalledOnce()
const wrapper = mountPopup({ title: 'T', showClose: true })
await wrapper.find('[aria-label="Close"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('renders default slot content', () => {
renderPopup({ title: 'T' }, { default: 'Body text here' })
expect(screen.getByRole('status')).toHaveTextContent('Body text here')
const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' })
expect(wrapper.text()).toContain('Body text here')
})
it('renders footer slots', () => {
renderPopup(
const wrapper = mountPopup(
{ title: 'T' },
{ 'footer-start': 'Left side', 'footer-end': 'Right side' }
)
const status = screen.getByRole('status')
expect(status).toHaveTextContent('Left side')
expect(status).toHaveTextContent('Right side')
expect(wrapper.text()).toContain('Left side')
expect(wrapper.text()).toContain('Right side')
})
it('positions bottom-right when specified', () => {
renderPopup({ title: 'T', position: 'bottom-right' })
expect(screen.getByRole('status')).toHaveAttribute(
'data-position',
'bottom-right'
)
const wrapper = mountPopup({ title: 'T', position: 'bottom-right' })
const root = wrapper.find('[role="status"]')
expect(root.attributes('data-position')).toBe('bottom-right')
})
})

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -14,8 +13,7 @@ function mockScrollWidth(el: HTMLElement, scrollWidth: number) {
describe(TextTicker, () => {
let rafCallbacks: ((time: number) => void)[]
let user: ReturnType<typeof userEvent.setup>
let cleanup: (() => void) | undefined
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
vi.useFakeTimers()
@@ -25,35 +23,32 @@ describe(TextTicker, () => {
return rafCallbacks.length
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
})
afterEach(() => {
cleanup?.()
wrapper?.unmount()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('renders slot content', () => {
const { unmount } = render(TextTicker, {
wrapper = mount(TextTicker, {
slots: { default: 'Hello World' }
})
cleanup = unmount
expect(screen.getByText('Hello World')).toBeInTheDocument()
expect(wrapper.text()).toBe('Hello World')
})
it('scrolls on hover after delay', async () => {
const { unmount } = render(TextTicker, {
wrapper = mount(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
cleanup = unmount
const el = screen.getByText('Very long text that overflows')
const el = wrapper.element as HTMLElement
mockScrollWidth(el, 300)
await nextTick()
await user.hover(el)
await wrapper.trigger('mouseenter')
await nextTick()
expect(rafCallbacks.length).toBe(0)
@@ -67,21 +62,19 @@ describe(TextTicker, () => {
})
it('cancels delayed scroll on mouse leave before delay elapses', async () => {
const { unmount } = render(TextTicker, {
wrapper = mount(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
cleanup = unmount
const el = screen.getByText('Very long text that overflows')
mockScrollWidth(el, 300)
mockScrollWidth(wrapper.element as HTMLElement, 300)
await nextTick()
await user.hover(el)
await wrapper.trigger('mouseenter')
await nextTick()
vi.advanceTimersByTime(200)
await user.unhover(el)
await wrapper.trigger('mouseleave')
await nextTick()
vi.advanceTimersByTime(350)
@@ -90,17 +83,16 @@ describe(TextTicker, () => {
})
it('resets scroll position on mouse leave', async () => {
const { unmount } = render(TextTicker, {
wrapper = mount(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
cleanup = unmount
const el = screen.getByText('Very long text that overflows')
const el = wrapper.element as HTMLElement
mockScrollWidth(el, 300)
await nextTick()
await user.hover(el)
await wrapper.trigger('mouseenter')
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()
@@ -108,22 +100,19 @@ describe(TextTicker, () => {
rafCallbacks[0](performance.now() + 500)
expect(el.scrollLeft).toBeGreaterThan(0)
await user.unhover(el)
await wrapper.trigger('mouseleave')
await nextTick()
expect(el.scrollLeft).toBe(0)
})
it('does not scroll when content fits', async () => {
const { unmount } = render(TextTicker, {
wrapper = mount(TextTicker, {
slots: { default: 'Short' }
})
cleanup = unmount
const el = screen.getByText('Short')
await nextTick()
await user.hover(el)
await wrapper.trigger('mouseenter')
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()

View File

@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import MarqueeLine from './MarqueeLine.vue'
import TextTickerMultiLine from './TextTickerMultiLine.vue'
type Callback = () => void
@@ -40,38 +41,23 @@ function mockElementSize(
}
describe(TextTickerMultiLine, () => {
let unmountFn: () => void
let wrapper: ReturnType<typeof mount>
afterEach(() => {
unmountFn?.()
wrapper?.unmount()
resizeCallbacks.length = 0
mutationCallbacks.length = 0
})
function renderComponent(text: string) {
const result = render(TextTickerMultiLine, {
function mountComponent(text: string) {
wrapper = mount(TextTickerMultiLine, {
slots: { default: text }
})
unmountFn = result.unmount
return {
...result,
container: result.container as HTMLElement
}
return wrapper
}
function getMeasureEl(container: HTMLElement): HTMLElement {
// eslint-disable-next-line testing-library/no-node-access
return container.querySelector('[aria-hidden="true"]') as HTMLElement
}
function getVisibleLines(container: HTMLElement): HTMLElement[] {
/* eslint-disable testing-library/no-node-access */
return Array.from(
container.querySelectorAll<HTMLElement>(
'div.overflow-hidden:not([aria-hidden])'
)
)
/* eslint-enable testing-library/no-node-access */
function getMeasureEl(): HTMLElement {
return wrapper.find('[aria-hidden="true"]').element as HTMLElement
}
async function triggerSplitLines() {
@@ -80,42 +66,40 @@ describe(TextTickerMultiLine, () => {
}
it('renders slot content', () => {
renderComponent('Load Checkpoint')
expect(
screen.getAllByText('Load Checkpoint').length
).toBeGreaterThanOrEqual(1)
mountComponent('Load Checkpoint')
expect(wrapper.text()).toContain('Load Checkpoint')
})
it('renders a single line when text fits', async () => {
const { container } = renderComponent('Short')
mockElementSize(getMeasureEl(container), 200, 100)
it('renders a single MarqueeLine when text fits', async () => {
mountComponent('Short')
mockElementSize(getMeasureEl(), 200, 100)
await triggerSplitLines()
expect(getVisibleLines(container)).toHaveLength(1)
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(1)
})
it('renders two lines when text overflows', async () => {
const { container } = renderComponent('Load Checkpoint Loader Simple')
mockElementSize(getMeasureEl(container), 100, 300)
it('renders two MarqueeLines when text overflows', async () => {
mountComponent('Load Checkpoint Loader Simple')
mockElementSize(getMeasureEl(), 100, 300)
await triggerSplitLines()
expect(getVisibleLines(container)).toHaveLength(2)
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(2)
})
it('splits text at word boundary when overflowing', async () => {
const { container } = renderComponent('Load Checkpoint Loader')
mockElementSize(getMeasureEl(container), 100, 200)
mountComponent('Load Checkpoint Loader')
mockElementSize(getMeasureEl(), 100, 200)
await triggerSplitLines()
const lines = getVisibleLines(container)
expect(lines[0].textContent).toBe('Load')
expect(lines[1].textContent).toBe('Checkpoint Loader')
const lines = wrapper.findAllComponents(MarqueeLine)
expect(lines[0].text()).toBe('Load')
expect(lines[1].text()).toBe('Checkpoint Loader')
})
it('has hidden measurement element with aria-hidden', () => {
const { container } = renderComponent('Test')
const measureEl = getMeasureEl(container)
expect(measureEl).toBeInTheDocument()
expect(measureEl).toHaveClass('invisible')
mountComponent('Test')
const measureEl = wrapper.find('[aria-hidden="true"]')
expect(measureEl.exists()).toBe(true)
expect(measureEl.classes()).toContain('invisible')
})
})

View File

@@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import type { FlattenedItem } from 'reka-ui'
import { nextTick, ref } from 'vue'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -93,7 +92,7 @@ describe('TreeExplorerV2Node', () => {
}
}
function renderComponent(
function mountComponent(
props: Record<string, unknown> = {},
options: {
provide?: Record<string, unknown>
@@ -101,76 +100,68 @@ describe('TreeExplorerV2Node', () => {
} = {}
) {
const treeItemStub = options.treeItemStub ?? createTreeItemStub()
const onNodeClick = vi.fn()
const { container } = render(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }
return {
wrapper: mount(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }
},
provide: {
...options.provide
}
},
provide: {
...options.provide
props: {
item: createMockItem('node'),
...props
}
},
props: {
item: createMockItem('node'),
onNodeClick,
...props
}
})
return { container, treeItemStub, onNodeClick }
}
function getTreeNode(container: Element) {
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
return container.querySelector('div.group\\/tree-node')! as HTMLElement
}),
treeItemStub
}
}
describe('handleClick', () => {
it('emits nodeClick event when clicked', async () => {
const user = userEvent.setup()
const { container, onNodeClick } = renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node')
})
const nodeDiv = getTreeNode(container)
await user.click(nodeDiv)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('click')
expect(onNodeClick).toHaveBeenCalled()
expect(onNodeClick.mock.calls[0][0]).toMatchObject({
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(wrapper.emitted('nodeClick')?.[0]?.[0]).toMatchObject({
type: 'node',
label: 'Test Label'
})
})
it('calls handleToggle for folder items', async () => {
const user = userEvent.setup()
const treeItemStub = createTreeItemStub()
const { container, onNodeClick } = renderComponent(
const { wrapper } = mountComponent(
{ item: createMockItem('folder') },
{ treeItemStub }
)
const folderDiv = getTreeNode(container)
await user.click(folderDiv)
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('click')
expect(onNodeClick).toHaveBeenCalled()
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(treeItemStub.handleToggle).toHaveBeenCalled()
})
it('does not call handleToggle for node items', async () => {
const user = userEvent.setup()
const treeItemStub = createTreeItemStub()
const { container, onNodeClick } = renderComponent(
const { wrapper } = mountComponent(
{ item: createMockItem('node') },
{ treeItemStub }
)
const nodeDiv = getTreeNode(container)
await user.click(nodeDiv)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('click')
expect(onNodeClick).toHaveBeenCalled()
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(treeItemStub.handleToggle).not.toHaveBeenCalled()
})
})
@@ -180,7 +171,7 @@ describe('TreeExplorerV2Node', () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
const nodeItem = createMockItem('node')
const { container } = renderComponent(
const { wrapper } = mountComponent(
{ item: nodeItem },
{
provide: {
@@ -189,8 +180,8 @@ describe('TreeExplorerV2Node', () => {
}
)
const nodeDiv = getTreeNode(container)
await fireEvent.contextMenu(nodeDiv)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('contextmenu')
expect(contextMenuNode.value).toEqual(nodeItem.value)
})
@@ -202,7 +193,7 @@ describe('TreeExplorerV2Node', () => {
label: 'Stale'
} as RenderedTreeExplorerNode)
const { container } = renderComponent(
const { wrapper } = mountComponent(
{ item: createMockItem('folder') },
{
provide: {
@@ -211,8 +202,8 @@ describe('TreeExplorerV2Node', () => {
}
)
const folderDiv = getTreeNode(container)
await fireEvent.contextMenu(folderDiv)
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('contextmenu')
expect(contextMenuNode.value).toBeNull()
})
@@ -225,53 +216,47 @@ describe('TreeExplorerV2Node', () => {
it('shows delete button for user blueprints', () => {
mockIsUserBlueprint.mockReturnValue(true)
renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: 'SubgraphBlueprint.test' }
})
})
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(true)
})
it('hides delete button for non-blueprint nodes', () => {
mockIsUserBlueprint.mockReturnValue(false)
renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: 'KSampler' }
})
})
expect(
screen.queryByRole('button', { name: 'Delete' })
).not.toBeInTheDocument()
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(false)
})
it('always shows bookmark button', () => {
mockIsUserBlueprint.mockReturnValue(true)
renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: 'SubgraphBlueprint.test' }
})
})
expect(
screen.getByRole('button', { name: 'icon.bookmark' })
).toBeInTheDocument()
expect(wrapper.find('[aria-label="icon.bookmark"]').exists()).toBe(true)
})
it('calls deleteBlueprint when delete button is clicked', async () => {
const user = userEvent.setup()
mockIsUserBlueprint.mockReturnValue(true)
const nodeName = 'SubgraphBlueprint.test'
renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: nodeName }
})
})
const deleteButton = screen.getByRole('button', { name: 'Delete' })
await user.click(deleteButton)
await wrapper.find('[aria-label="Delete"]').trigger('click')
expect(mockDeleteBlueprint).toHaveBeenCalledWith(nodeName)
})
@@ -279,47 +264,40 @@ describe('TreeExplorerV2Node', () => {
describe('rendering', () => {
it('renders node icon for node type', () => {
const { container } = renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node')
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('i.icon-\\[comfy--node\\]')).toBeTruthy()
expect(wrapper.find('i.icon-\\[comfy--node\\]').exists()).toBe(true)
})
it('renders folder icon for folder type', () => {
const { container } = renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('folder', { icon: 'icon-[lucide--folder]' })
})
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(
container.querySelector('i.icon-\\[lucide--folder\\]')
).toBeTruthy()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(wrapper.find('i.icon-\\[lucide--folder\\]').exists()).toBe(true)
})
it('renders label text', () => {
renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node', { label: 'My Node' })
})
expect(screen.getByText('My Node')).toBeInTheDocument()
expect(wrapper.text()).toContain('My Node')
})
it('renders chevron for folder with children', () => {
const { container } = renderComponent({
const { wrapper } = mountComponent({
item: {
...createMockItem('folder'),
hasChildren: true
}
})
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(
container.querySelector('i.icon-\\[lucide--chevron-down\\]')
).toBeTruthy()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(wrapper.find('i.icon-\\[lucide--chevron-down\\]').exists()).toBe(
true
)
})
})
@@ -329,75 +307,75 @@ describe('TreeExplorerV2Node', () => {
})
it('sets draggable attribute on node items', () => {
const { container } = renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node')
})
const nodeDiv = getTreeNode(container)
expect(nodeDiv.getAttribute('draggable')).toBe('true')
const nodeDiv = wrapper.find('div.group\\/tree-node')
expect(nodeDiv.attributes('draggable')).toBe('true')
})
it('does not set draggable on folder items', () => {
const { container } = renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('folder')
})
const folderDiv = getTreeNode(container)
expect(folderDiv.getAttribute('draggable')).toBeNull()
const folderDiv = wrapper.find('div.group\\/tree-node')
expect(folderDiv.attributes('draggable')).toBeUndefined()
})
it('calls startDrag with native mode on dragstart', async () => {
const mockData = { name: 'TestNode' }
const { container } = renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = getTreeNode(container)
await fireEvent.dragStart(nodeDiv)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('dragstart')
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
})
it('does not call startDrag for folder items on dragstart', async () => {
const { container } = renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('folder')
})
const folderDiv = getTreeNode(container)
await fireEvent.dragStart(folderDiv)
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('dragstart')
expect(mockStartDrag).not.toHaveBeenCalled()
})
it('calls handleNativeDrop on dragend with drop coordinates', async () => {
const mockData = { name: 'TestNode' }
const { container } = renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = getTreeNode(container)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await fireEvent.dragStart(nodeDiv)
await nodeDiv.trigger('dragstart')
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
Object.defineProperty(dragEndEvent, 'clientX', { value: 100 })
Object.defineProperty(dragEndEvent, 'clientY', { value: 200 })
nodeDiv.dispatchEvent(dragEndEvent)
await nextTick()
await nodeDiv.element.dispatchEvent(dragEndEvent)
await wrapper.vm.$nextTick()
expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200)
})
it('calls handleNativeDrop regardless of dropEffect', async () => {
const mockData = { name: 'TestNode' }
const { container } = renderComponent({
const { wrapper } = mountComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = getTreeNode(container)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await fireEvent.dragStart(nodeDiv)
await nodeDiv.trigger('dragstart')
mockHandleNativeDrop.mockClear()
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
@@ -407,8 +385,8 @@ describe('TreeExplorerV2Node', () => {
value: { dropEffect: 'none' }
})
nodeDiv.dispatchEvent(dragEndEvent)
await nextTick()
await nodeDiv.element.dispatchEvent(dragEndEvent)
await wrapper.vm.$nextTick()
expect(mockHandleNativeDrop).toHaveBeenCalledWith(300, 400)
})

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
@@ -46,7 +46,7 @@ describe('VirtualGrid', () => {
mockedHeight.value = 200
mockedScrollY.value = 0
render(VirtualGrid, {
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
@@ -60,14 +60,16 @@ describe('VirtualGrid', () => {
<div class="test-item">{{ item.name }}</div>
</template>`
},
container: document.body.appendChild(document.createElement('div'))
attachTo: document.body
})
await nextTick()
const renderedItems = screen.getAllByText(/^Item \d+$/)
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length).toBeLessThan(items.length)
wrapper.unmount()
})
it('provides correct index in slot props', async () => {
@@ -77,7 +79,7 @@ describe('VirtualGrid', () => {
mockedHeight.value = 200
mockedScrollY.value = 0
render(VirtualGrid, {
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
@@ -92,7 +94,7 @@ describe('VirtualGrid', () => {
return null
}
},
container: document.body.appendChild(document.createElement('div'))
attachTo: document.body
})
await nextTick()
@@ -102,6 +104,8 @@ describe('VirtualGrid', () => {
for (let i = 1; i < receivedIndices.length; i++) {
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
}
wrapper.unmount()
})
it('respects maxColumns prop', async () => {
@@ -110,29 +114,28 @@ describe('VirtualGrid', () => {
mockedHeight.value = 200
mockedScrollY.value = 0
const { container } = render(VirtualGrid, {
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
maxColumns: 2
},
container: document.body.appendChild(document.createElement('div'))
attachTo: document.body
})
await nextTick()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const gridElement = container.querySelector(
'[style*="display: grid"]'
) as HTMLElement
expect(gridElement).not.toBeNull()
expect(gridElement.style.gridTemplateColumns).toBe(
'repeat(2, minmax(0, 1fr))'
)
const gridElement = wrapper.find('[style*="display: grid"]')
expect(gridElement.exists()).toBe(true)
const gridEl = gridElement.element as HTMLElement
expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
wrapper.unmount()
})
it('renders empty when no items provided', async () => {
render(VirtualGrid, {
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items: [],
gridStyle: defaultGridStyle
@@ -146,8 +149,10 @@ describe('VirtualGrid', () => {
await nextTick()
const renderedItems = screen.queryAllByText(/^Item \d+$/)
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBe(0)
wrapper.unmount()
})
it('emits approach-end for single-column list when scrolled near bottom', async () => {
@@ -156,9 +161,7 @@ describe('VirtualGrid', () => {
mockedHeight.value = 600
mockedScrollY.value = 0
const onApproachEnd = vi.fn()
render(VirtualGrid, {
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: {
@@ -168,20 +171,19 @@ describe('VirtualGrid', () => {
defaultItemHeight: 48,
defaultItemWidth: 200,
maxColumns: 1,
bufferRows: 1,
onApproachEnd
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
container: document.body.appendChild(document.createElement('div'))
attachTo: document.body
})
await nextTick()
expect(onApproachEnd).not.toHaveBeenCalled()
expect(wrapper.emitted('approach-end')).toBeUndefined()
// Scroll near the end: 50 items * 48px = 2400px total
// viewRows = ceil(600/48) = 13, buffer = 1
@@ -193,7 +195,9 @@ describe('VirtualGrid', () => {
mockedScrollY.value = 1680
await nextTick()
expect(onApproachEnd).toHaveBeenCalled()
expect(wrapper.emitted('approach-end')).toBeDefined()
wrapper.unmount()
})
it('does not emit approach-end without maxColumns in single-column layout', async () => {
@@ -204,9 +208,7 @@ describe('VirtualGrid', () => {
mockedHeight.value = 600
mockedScrollY.value = 0
const onApproachEnd = vi.fn()
render(VirtualGrid, {
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: {
@@ -216,15 +218,14 @@ describe('VirtualGrid', () => {
defaultItemHeight: 48,
defaultItemWidth: 200,
// No maxColumns — cols will be floor(400/200) = 2
bufferRows: 1,
onApproachEnd
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
container: document.body.appendChild(document.createElement('div'))
attachTo: document.body
})
await nextTick()
@@ -236,7 +237,9 @@ describe('VirtualGrid', () => {
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
// The approach-end never fires at the correct scroll position
expect(onApproachEnd).not.toHaveBeenCalled()
expect(wrapper.emitted('approach-end')).toBeUndefined()
wrapper.unmount()
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
@@ -245,7 +248,7 @@ describe('VirtualGrid', () => {
mockedScrollY.value = 0
const items = createItems(20)
render(VirtualGrid, {
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
@@ -259,13 +262,15 @@ describe('VirtualGrid', () => {
<div class="test-item">{{ item.name }}</div>
</template>`
},
container: document.body.appendChild(document.createElement('div'))
attachTo: document.body
})
await nextTick()
const renderedItems = screen.getAllByText(/^Item \d+$/)
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length % 4).toBe(0)
wrapper.unmount()
})
})

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -8,23 +7,10 @@ import type {
WorkflowMenuItem
} from '@/types/workflowMenuItem'
const MenuItemStub = {
template:
'<div data-testid="menu-item" @click="$emit(\'select\')"><slot /></div>',
emits: ['select']
}
const SeparatorStub = {
template: '<hr data-testid="menu-separator" />'
}
function renderList(items: WorkflowMenuItem[]) {
return render(WorkflowActionsList, {
props: {
items,
itemComponent: MenuItemStub,
separatorComponent: SeparatorStub
}
function createWrapper(items: WorkflowMenuItem[]) {
return shallowMount(WorkflowActionsList, {
props: { items },
global: { renderStubDefaultSlot: true }
})
}
@@ -34,9 +20,10 @@ describe('WorkflowActionsList', () => {
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
]
renderList(items)
const wrapper = createWrapper(items)
expect(screen.getByText('Save')).toBeInTheDocument()
expect(wrapper.text()).toContain('Save')
expect(wrapper.find('.pi-save').exists()).toBe(true)
})
it('renders separator items', () => {
@@ -46,23 +33,24 @@ describe('WorkflowActionsList', () => {
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
]
renderList(items)
const wrapper = createWrapper(items)
const html = wrapper.html()
screen.getByTestId('menu-separator')
screen.getByText('Before')
screen.getByText('After')
expect(html).toContain('dropdown-menu-separator-stub')
expect(wrapper.text()).toContain('Before')
expect(wrapper.text()).toContain('After')
})
it('dispatches command on select', async () => {
const user = userEvent.setup()
const command = vi.fn()
const items: WorkflowMenuItem[] = [
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
]
renderList(items)
const wrapper = createWrapper(items)
const item = wrapper.findComponent({ name: 'DropdownMenuItem' })
await item.vm.$emit('select')
await user.click(screen.getByTestId('menu-item'))
expect(command).toHaveBeenCalledOnce()
})
@@ -77,9 +65,9 @@ describe('WorkflowActionsList', () => {
}
]
renderList(items)
const wrapper = createWrapper(items)
screen.getByText('NEW')
expect(wrapper.text()).toContain('NEW')
})
it('does not render items with visible set to false', () => {
@@ -94,10 +82,10 @@ describe('WorkflowActionsList', () => {
{ id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() }
]
renderList(items)
const wrapper = createWrapper(items)
expect(screen.queryByText('Hidden Item')).toBeNull()
screen.getByText('Shown Item')
expect(wrapper.text()).not.toContain('Hidden Item')
expect(wrapper.text()).toContain('Shown Item')
})
it('does not render badge when absent', () => {
@@ -105,8 +93,8 @@ describe('WorkflowActionsList', () => {
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
]
renderList(items)
const wrapper = createWrapper(items)
expect(screen.queryByText('NEW')).toBeNull()
expect(wrapper.text()).not.toContain('NEW')
})
})

View File

@@ -1,91 +1,86 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'
function renderEditor(points: CurvePoint[], extraProps = {}) {
const { container } = render(CurveEditor, {
function mountEditor(points: CurvePoint[], extraProps = {}) {
return mount(CurveEditor, {
props: { modelValue: points, ...extraProps }
})
return { container }
}
function getCurvePath() {
return screen.getByTestId('curve-path')
function getCurvePath(wrapper: ReturnType<typeof mount>) {
return wrapper.find('[data-testid="curve-path"]')
}
describe('CurveEditor', () => {
it('renders SVG with curve path', () => {
const { container } = renderEditor([
const wrapper = mountEditor([
[0, 0],
[1, 1]
])
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(container.querySelector('svg')).toBeInTheDocument()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
const curvePath = getCurvePath()
expect(curvePath).toBeInTheDocument()
expect(curvePath.getAttribute('d')).toBeTruthy()
expect(wrapper.find('svg').exists()).toBe(true)
const curvePath = getCurvePath(wrapper)
expect(curvePath.exists()).toBe(true)
expect(curvePath.attributes('d')).toBeTruthy()
})
it('renders a circle for each control point', () => {
const { container } = renderEditor([
const wrapper = mountEditor([
[0, 0],
[0.5, 0.7],
[1, 1]
])
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(container.querySelectorAll('circle')).toHaveLength(3)
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(wrapper.findAll('circle')).toHaveLength(3)
})
it('renders histogram path when provided', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
renderEditor(
const wrapper = mountEditor(
[
[0, 0],
[1, 1]
],
{ histogram }
)
const histogramPath = screen.getByTestId('histogram-path')
expect(histogramPath).toBeInTheDocument()
expect(histogramPath.getAttribute('d')).toContain('M0,1')
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
expect(histogramPath.exists()).toBe(true)
expect(histogramPath.attributes('d')).toContain('M0,1')
})
it('does not render histogram path when not provided', () => {
renderEditor([
const wrapper = mountEditor([
[0, 0],
[1, 1]
])
expect(screen.queryByTestId('histogram-path')).not.toBeInTheDocument()
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
})
it('returns empty path with fewer than 2 points', () => {
renderEditor([[0.5, 0.5]])
expect(getCurvePath().getAttribute('d')).toBe('')
const wrapper = mountEditor([[0.5, 0.5]])
expect(getCurvePath(wrapper).attributes('d')).toBe('')
})
it('generates path starting with M and containing L segments', () => {
renderEditor([
const wrapper = mountEditor([
[0, 0],
[0.5, 0.8],
[1, 1]
])
const d = getCurvePath().getAttribute('d')!
const d = getCurvePath(wrapper).attributes('d')!
expect(d).toMatch(/^M/)
expect(d).toContain('L')
})
it('curve path only spans the x-range of control points', () => {
renderEditor([
const wrapper = mountEditor([
[0.2, 0.3],
[0.8, 0.9]
])
const d = getCurvePath().getAttribute('d')!
const d = getCurvePath(wrapper).attributes('d')!
const xValues = d
.split(/[ML]/)
.filter(Boolean)
@@ -100,22 +95,19 @@ describe('CurveEditor', () => {
[0.5, 0.5],
[1, 1]
]
const { container } = renderEditor(points)
const wrapper = mountEditor(points)
expect(wrapper.findAll('circle')).toHaveLength(3)
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
expect(container.querySelectorAll('circle')).toHaveLength(3)
await fireEvent.pointerDown(container.querySelectorAll('circle')[1], {
await wrapper.findAll('circle')[1].trigger('pointerdown', {
button: 2,
pointerId: 1
})
expect(container.querySelectorAll('circle')).toHaveLength(2)
expect(wrapper.findAll('circle')).toHaveLength(2)
await fireEvent.pointerDown(container.querySelectorAll('circle')[0], {
await wrapper.findAll('circle')[0].trigger('pointerdown', {
button: 2,
pointerId: 1
})
expect(container.querySelectorAll('circle')).toHaveLength(2)
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
expect(wrapper.findAll('circle')).toHaveLength(2)
})
})

View File

@@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
@@ -11,16 +10,10 @@ const i18n = createI18n({
messages: { en: {} }
})
function renderOption(
props?: Partial<{
credits: number
description: string
selected: boolean
onSelect: () => void
}>
) {
const user = userEvent.setup()
const result = render(CreditTopUpOption, {
const mountOption = (
props?: Partial<{ credits: number; description: string; selected: boolean }>
) =>
mount(CreditTopUpOption, {
props: {
credits: 1000,
description: '~100 videos*',
@@ -31,30 +24,25 @@ function renderOption(
plugins: [i18n]
}
})
return { user, ...result }
}
describe('CreditTopUpOption', () => {
it('renders credit amount and description', () => {
renderOption({ credits: 5000, description: '~500 videos*' })
expect(screen.getByText('5,000')).toBeInTheDocument()
expect(screen.getByText('~500 videos*')).toBeInTheDocument()
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
expect(wrapper.text()).toContain('5,000')
expect(wrapper.text()).toContain('~500 videos*')
})
it('applies unselected styling when not selected', () => {
const { container } = renderOption({ selected: false })
// eslint-disable-next-line testing-library/no-node-access
const rootDiv = container.firstElementChild as HTMLElement
expect(rootDiv).toHaveClass(
'bg-component-node-disabled',
'border-transparent'
const wrapper = mountOption({ selected: false })
expect(wrapper.find('div').classes()).toContain(
'bg-component-node-disabled'
)
expect(wrapper.find('div').classes()).toContain('border-transparent')
})
it('emits select event when clicked', async () => {
const selectSpy = vi.fn()
const { user } = renderOption({ onSelect: selectSpy })
await user.click(screen.getByText('1,000'))
expect(selectSpy).toHaveBeenCalledOnce()
const wrapper = mountOption()
await wrapper.find('div').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
})
})

View File

@@ -1,15 +1,12 @@
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tag from 'primevue/tag'
import Tooltip from 'primevue/tooltip'
import { defineComponent, h } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SettingItem from '@/platform/settings/components/SettingItem.vue'
import type { SettingParams } from '@/platform/settings/types'
const i18n = createI18n({
legacy: false,
@@ -20,72 +17,60 @@ vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: vi.fn()
}))
const FormItemStub = defineComponent({
name: 'FormItem',
props: {
item: { type: Object, default: () => ({}) },
id: { type: String, default: undefined },
formValue: { type: null, default: undefined }
},
setup(props) {
return () =>
h('div', { 'data-testid': 'form-item-data' }, JSON.stringify(props.item))
}
})
describe('SettingItem', () => {
function renderComponent(setting: SettingParams) {
return render(SettingItem, {
const mountComponent = (props: Record<string, unknown>, options = {}) => {
return mount(SettingItem, {
global: {
plugins: [PrimeVue, i18n, createPinia()],
components: { Tag },
stubs: {
FormItem: FormItemStub,
'i-material-symbols:experiment-outline': true
components: {
Tag
},
directives: { tooltip: Tooltip }
directives: {
tooltip: Tooltip
},
stubs: {
'i-material-symbols:experiment-outline': true
}
},
props: { setting }
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
props,
...options
})
}
function getFormItemData(container: Element) {
// eslint-disable-next-line testing-library/no-node-access
const el = container.querySelector('[data-testid="form-item-data"]')
return JSON.parse(el!.textContent!)
}
it('translates options that use legacy type', () => {
const { container } = renderComponent(
fromAny({
const wrapper = mountComponent({
setting: {
id: 'Comfy.NodeInputConversionSubmenus',
name: 'Node Input Conversion Submenus',
type: 'combo',
defaultValue: 'Top',
value: 'Top',
options: () => ['Correctly Translated']
})
)
}
})
const data = getFormItemData(container)
expect(data.options).toEqual([
// Check the FormItem component's item prop for the options
const formItem = wrapper.findComponent({ name: 'FormItem' })
const options = formItem.props('item').options
expect(options).toEqual([
{ text: 'Correctly Translated', value: 'Correctly Translated' }
])
})
it('handles tooltips with @ symbols without errors', () => {
const { container } = renderComponent(
fromAny({
id: 'Comfy.NodeInputConversionSubmenus',
const wrapper = mountComponent({
setting: {
id: 'TestSetting',
name: 'Test Setting',
type: 'boolean',
defaultValue: false,
tooltip:
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
})
)
}
})
const data = getFormItemData(container)
expect(data.tooltip).toBe(
// Should not throw an error and tooltip should be preserved as-is
const formItem = wrapper.findComponent({ name: 'FormItem' })
expect(formItem.props('item').tooltip).toBe(
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
)
})

View File

@@ -1,17 +1,40 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Badge from 'primevue/badge'
import Button from '@/components/ui/button/Button.vue'
import Column from 'primevue/column'
import PrimeVue from 'primevue/config'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import Tooltip from 'primevue/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, onMounted, ref } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { render, screen, waitFor } from '@testing-library/vue'
import type { AuditLog } from '@/services/customerEventsService'
import { EventType } from '@/services/customerEventsService'
import UsageLogsTable from './UsageLogsTable.vue'
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
loading: boolean
error: string | null
events: Partial<AuditLog>[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
dataTableFirst: number
tooltipContentMap: Map<string, string>
loadEvents: () => Promise<void>
refresh: () => Promise<void>
onPageChange: (event: { page: number }) => void
}
// Mock the customerEventsService
const mockCustomerEventsService = vi.hoisted(() => ({
getMyEvents: vi.fn(),
formatEventType: vi.fn(),
@@ -20,7 +43,7 @@ const mockCustomerEventsService = vi.hoisted(() => ({
formatDate: vi.fn(),
hasAdditionalInfo: vi.fn(),
getTooltipContent: vi.fn(),
error: { value: null as string | null },
error: { value: null },
isLoading: { value: false }
}))
@@ -34,10 +57,7 @@ vi.mock('@/services/customerEventsService', () => ({
}
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
// Create i18n instance
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -56,115 +76,78 @@ const i18n = createI18n({
}
})
const globalConfig = {
plugins: [PrimeVue, i18n, createTestingPinia()],
directives: { tooltip: Tooltip }
}
/**
* The component starts with loading=true and only loads data when refresh()
* is called via template ref. This wrapper auto-calls refresh on mount.
*/
const AutoRefreshWrapper = defineComponent({
components: { UsageLogsTable },
setup() {
const tableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
onMounted(async () => {
await tableRef.value?.refresh()
})
return { tableRef }
},
template: '<UsageLogsTable ref="tableRef" />'
})
function makeEventsResponse(
events: Partial<AuditLog>[],
overrides: Record<string, unknown> = {}
) {
return {
events,
total: events.length,
describe('UsageLogsTable', () => {
const mockEventsResponse = {
events: [
{
event_id: 'event-1',
event_type: 'credit_added',
params: {
amount: 1000,
transaction_id: 'txn-123'
},
createdAt: '2024-01-01T10:00:00Z'
},
{
event_id: 'event-2',
event_type: 'api_usage_completed',
params: {
api_name: 'Image Generation',
model: 'sdxl-base',
duration: 5000
},
createdAt: '2024-01-02T10:00:00Z'
}
],
total: 2,
page: 1,
limit: 7,
totalPages: 1,
...overrides
totalPages: 1
}
}
describe('UsageLogsTable', () => {
const mockEventsResponse = makeEventsResponse([
{
event_id: 'event-1',
event_type: 'credit_added',
params: {
amount: 1000,
transaction_id: 'txn-123'
},
createdAt: '2024-01-01T10:00:00Z'
},
{
event_id: 'event-2',
event_type: 'api_usage_completed',
params: {
api_name: 'Image Generation',
model: 'sdxl-base',
duration: 5000
},
createdAt: '2024-01-02T10:00:00Z'
}
])
beforeEach(() => {
vi.clearAllMocks()
// Setup default service mock implementations
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockCustomerEventsService.formatEventType.mockImplementation(
(type: string) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'Credits Added'
case EventType.ACCOUNT_CREATED:
return 'Account Created'
case EventType.API_USAGE_COMPLETED:
return 'API Usage'
default:
return type
}
mockCustomerEventsService.formatEventType.mockImplementation((type) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'Credits Added'
case EventType.ACCOUNT_CREATED:
return 'Account Created'
case EventType.API_USAGE_COMPLETED:
return 'API Usage'
default:
return type
}
)
mockCustomerEventsService.getEventSeverity.mockImplementation(
(type: string) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'success'
case EventType.ACCOUNT_CREATED:
return 'info'
case EventType.API_USAGE_COMPLETED:
return 'warning'
default:
return 'info'
}
})
mockCustomerEventsService.getEventSeverity.mockImplementation((type) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'success'
case EventType.ACCOUNT_CREATED:
return 'info'
case EventType.API_USAGE_COMPLETED:
return 'warning'
default:
return 'info'
}
)
mockCustomerEventsService.formatAmount.mockImplementation(
(amount: number) => {
if (!amount) return '0.00'
return (amount / 100).toFixed(2)
}
)
mockCustomerEventsService.formatDate.mockImplementation(
(dateString: string) => new Date(dateString).toLocaleDateString()
)
mockCustomerEventsService.hasAdditionalInfo.mockImplementation(
(event: AuditLog) => {
const { amount, api_name, model, ...otherParams } =
(event.params as Record<string, unknown>) ?? {}
return Object.keys(otherParams).length > 0
}
)
mockCustomerEventsService.getTooltipContent.mockImplementation(
() => '<strong>Transaction Id:</strong> txn-123'
)
})
mockCustomerEventsService.formatAmount.mockImplementation((amount) => {
if (!amount) return '0.00'
return (amount / 100).toFixed(2)
})
mockCustomerEventsService.formatDate.mockImplementation((dateString) => {
return new Date(dateString).toLocaleDateString()
})
mockCustomerEventsService.hasAdditionalInfo.mockImplementation((event) => {
const { amount, api_name, model, ...otherParams } = event.params || {}
return Object.keys(otherParams).length > 0
})
mockCustomerEventsService.getTooltipContent.mockImplementation(() => {
return '<strong>Transaction Id:</strong> txn-123'
})
mockCustomerEventsService.error.value = null
mockCustomerEventsService.isLoading.value = false
})
@@ -173,135 +156,200 @@ describe('UsageLogsTable', () => {
vi.restoreAllMocks()
})
function renderComponent() {
return render(UsageLogsTable, { global: globalConfig })
}
function renderWithAutoRefresh() {
return render(AutoRefreshWrapper, { global: globalConfig })
}
async function renderLoaded() {
const result = renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument()
const mountComponent = (options = {}) => {
return mount(UsageLogsTable, {
global: {
plugins: [PrimeVue, i18n, createTestingPinia()],
components: {
DataTable,
Column,
Badge,
Button,
Message,
ProgressSpinner
},
directives: {
tooltip: Tooltip
}
},
...options
})
return result
}
describe('loading states', () => {
it('shows loading spinner before refresh is called', () => {
renderComponent()
it('shows loading spinner when loading is true', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = true
await nextTick()
expect(screen.getByRole('progressbar')).toBeInTheDocument()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(DataTable).exists()).toBe(false)
})
it('shows error message when service returns null', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
mockCustomerEventsService.error.value = 'Failed to load events'
it('shows error message when error exists', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.error = 'Failed to load events'
vm.loading = false
await nextTick()
renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByText('Failed to load events')).toBeInTheDocument()
})
const messageComponent = wrapper.findComponent(Message)
expect(messageComponent.exists()).toBe(true)
expect(messageComponent.props('severity')).toBe('error')
expect(messageComponent.text()).toContain('Failed to load events')
})
it('shows error message when service throws', async () => {
mockCustomerEventsService.getMyEvents.mockRejectedValue(
new Error('Network error')
)
it('shows data table when loaded successfully', async () => {
const wrapper = mountComponent()
renderWithAutoRefresh()
const vm = wrapper.vm as ComponentInstance
// Wait for component to mount and load data
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument()
})
})
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
it('shows data table after loading completes', async () => {
await renderLoaded()
expect(
screen.queryByText('Failed to load events')
).not.toBeInTheDocument()
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Message).exists()).toBe(false)
})
})
describe('data rendering', () => {
it('renders event type badges', async () => {
await renderLoaded()
it('renders events data correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const dataTable = wrapper.findComponent(DataTable)
expect(dataTable.props('value')).toEqual(mockEventsResponse.events)
expect(dataTable.props('rows')).toBe(7)
expect(dataTable.props('paginator')).toBe(true)
expect(dataTable.props('lazy')).toBe(true)
})
it('renders badge for event types correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const badges = wrapper.findAllComponents(Badge)
expect(badges.length).toBeGreaterThan(0)
// Check if formatEventType and getEventSeverity are called
expect(mockCustomerEventsService.formatEventType).toHaveBeenCalled()
expect(mockCustomerEventsService.getEventSeverity).toHaveBeenCalled()
})
it('renders credit added details with formatted amount', async () => {
await renderLoaded()
it('renders different event details based on event type', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
expect(screen.getByText(/Added \$/)).toBeInTheDocument()
// Check if formatAmount is called for credit_added events
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
})
it('renders API usage details with api name and model', async () => {
await renderLoaded()
expect(screen.getByText('Image Generation')).toBeInTheDocument()
expect(screen.getByText(/sdxl-base/)).toBeInTheDocument()
})
it('renders account created details', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'event-3',
event_type: 'account_created',
params: {},
createdAt: '2024-01-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByText('Account initialized')).toBeInTheDocument()
})
})
it('renders formatted dates', async () => {
await renderLoaded()
expect(mockCustomerEventsService.formatDate).toHaveBeenCalled()
})
it('renders info buttons for events with additional info', async () => {
it('renders tooltip buttons for events with additional info', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
await renderLoaded()
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const infoButtons = screen.getAllByRole('button', {
name: 'Additional Info'
})
expect(infoButtons.length).toBeGreaterThan(0)
})
it('does not render info buttons when no additional info', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
await renderLoaded()
expect(
screen.queryByRole('button', { name: 'Additional Info' })
).not.toBeInTheDocument()
expect(mockCustomerEventsService.hasAdditionalInfo).toHaveBeenCalled()
})
})
describe('pagination', () => {
it('calls getMyEvents with initial page params', async () => {
await renderLoaded()
it('handles page change correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
// Simulate page change
const dataTable = wrapper.findComponent(DataTable)
await dataTable.vm.$emit('page', { page: 1 })
expect(vm.pagination.page).toBe(1) // page + 1
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
page: 2,
limit: 7
})
})
it('calculates dataTableFirst correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.pagination = { page: 2, limit: 7, total: 20, totalPages: 3 }
await nextTick()
expect(vm.dataTableFirst).toBe(7) // (2-1) * 7
})
})
describe('tooltip functionality', () => {
it('generates tooltip content map correctly', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
mockCustomerEventsService.getTooltipContent.mockReturnValue(
'<strong>Test:</strong> value'
)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const tooltipMap = vm.tooltipContentMap
expect(tooltipMap.get('event-1')).toBe('<strong>Test:</strong> value')
})
it('excludes events without additional info from tooltip map', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const tooltipMap = vm.tooltipContentMap
expect(tooltipMap.size).toBe(0)
})
})
describe('component methods', () => {
it('exposes refresh method', () => {
const wrapper = mountComponent()
expect(typeof wrapper.vm.refresh).toBe('function')
})
it('resets to first page on refresh', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.pagination.page = 3
await vm.refresh()
expect(vm.pagination.page).toBe(1)
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
page: 1,
limit: 7
@@ -309,52 +357,44 @@ describe('UsageLogsTable', () => {
})
})
describe('component methods', () => {
it('calls getMyEvents on refresh with page 1', async () => {
await renderLoaded()
describe('component lifecycle', () => {
it('initializes with correct default values', () => {
const wrapper = mountComponent()
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
const vm = wrapper.vm as ComponentInstance
expect(vm.events).toEqual([])
expect(vm.loading).toBe(true)
expect(vm.error).toBeNull()
expect(vm.pagination).toEqual({
page: 1,
limit: 7
limit: 7,
total: 0,
totalPages: 0
})
})
})
describe('EventType integration', () => {
it('renders credit_added event with correct detail template', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'event-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
])
)
it('uses EventType enum in template conditions', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
await renderLoaded()
vm.loading = false
vm.events = [
{
event_id: 'event-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
]
await nextTick()
expect(screen.getByText(/Added \$/)).toBeInTheDocument()
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
})
it('renders api_usage_completed event with correct detail template', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'event-2',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'Test API', model: 'test-model' },
createdAt: '2024-01-02T10:00:00Z'
}
])
)
await renderLoaded()
expect(screen.getByText('Test API')).toBeInTheDocument()
expect(screen.getByText(/test-model/)).toBeInTheDocument()
// Verify that the component can access EventType enum
expect(EventType.CREDIT_ADDED).toBe('credit_added')
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
})
})
})

View File

@@ -1,12 +1,14 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { Form } from '@primevue/forms'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
@@ -14,13 +16,11 @@ import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import ApiKeyForm from './ApiKeyForm.vue'
const mockStoreApiKey = vi.fn()
const mockLoadingRef = ref(false)
const mockLoading = vi.fn(() => false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
get loading() {
return mockLoadingRef.value
}
loading: mockLoading()
}))
}))
@@ -58,57 +58,62 @@ const i18n = createI18n({
describe('ApiKeyForm', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
vi.clearAllMocks()
mockStoreApiKey.mockReset()
mockLoadingRef.value = false
mockLoading.mockReset()
})
function renderComponent(props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const result = render(ApiKeyForm, {
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
return mount(ApiKeyForm, {
global: {
plugins: [PrimeVue, i18n],
plugins: [PrimeVue, createPinia(), i18n],
components: { Button, Form, InputText, Message }
},
props
})
return { ...result, user }
}
it('renders correctly with all required elements', () => {
renderComponent()
const wrapper = mountComponent()
expect(screen.getByRole('heading', { name: 'API Key' })).toBeInTheDocument()
expect(screen.getByLabelText('API Key')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
expect(wrapper.find('h1').text()).toBe('API Key')
expect(wrapper.find('label').text()).toBe('API Key')
expect(wrapper.findComponent(InputText).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
it('emits back event when back button is clicked', async () => {
const onBack = vi.fn()
const { user } = renderComponent({ onBack })
const wrapper = mountComponent()
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(onBack).toHaveBeenCalled()
await wrapper.findComponent(Button).trigger('click')
expect(wrapper.emitted('back')).toBeTruthy()
})
it('shows loading state when submitting', () => {
mockLoadingRef.value = true
const { container } = renderComponent()
it('shows loading state when submitting', async () => {
mockLoading.mockReturnValue(true)
const wrapper = mountComponent()
const input = wrapper.findComponent(InputText)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const submitButton = container.querySelector('button[type="submit"]')
expect(submitButton).toBeDisabled()
await input.setValue(
'comfyui-123456789012345678901234567890123456789012345678901234567890123456789012'
)
await wrapper.find('form').trigger('submit')
const buttons = wrapper.findAllComponents(Button)
const submitButton = buttons.find(
(btn) => btn.attributes('type') === 'submit'
)
expect(submitButton?.props('loading')).toBe(true)
})
it('displays help text and links correctly', () => {
renderComponent()
const wrapper = mountComponent()
expect(
screen.getByText('Need an API key?', { exact: false })
).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Get one here' })).toHaveAttribute(
'href',
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
`${getComfyPlatformBaseUrl()}/login`
)
})

View File

@@ -1,10 +1,8 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import GradientSlider from './GradientSlider.vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import { interpolateStops, stopsToGradient } from './gradients'
const TEST_STOPS: ColorStop[] = [
@@ -12,44 +10,40 @@ const TEST_STOPS: ColorStop[] = [
{ offset: 1, color: [255, 255, 255] }
]
function renderSlider(props: {
function mountSlider(props: {
stops?: ColorStop[]
modelValue: number
min?: number
max?: number
step?: number
}) {
return render(GradientSlider, {
return mount(GradientSlider, {
props: { stops: TEST_STOPS, ...props }
})
}
describe('GradientSlider', () => {
it('passes min and max to SliderRoot', () => {
renderSlider({
it('passes min, max, step to SliderRoot', () => {
const wrapper = mountSlider({
modelValue: 50,
min: -100,
max: 100,
step: 5
})
const thumb = screen.getByRole('slider', { hidden: true })
expect(thumb).toBeInTheDocument()
expect(thumb).toHaveAttribute('aria-valuemin', '-100')
expect(thumb).toHaveAttribute('aria-valuemax', '100')
const thumb = wrapper.find('[role="slider"]')
expect(thumb.attributes('aria-valuemin')).toBe('-100')
expect(thumb.attributes('aria-valuemax')).toBe('100')
})
it('renders slider root with track and thumb', () => {
const { container } = renderSlider({ modelValue: 0 })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('[data-slider-impl]')).toBeInTheDocument()
expect(screen.getByRole('slider', { hidden: true })).toBeInTheDocument()
const wrapper = mountSlider({ modelValue: 0 })
expect(wrapper.find('[data-slider-impl]').exists()).toBe(true)
expect(wrapper.find('[role="slider"]').exists()).toBe(true)
})
it('does not render SliderRange', () => {
const { container } = renderSlider({ modelValue: 50 })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const range = container.querySelector('[data-slot="slider-range"]')
expect(range).not.toBeInTheDocument()
const wrapper = mountSlider({ modelValue: 50 })
expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false)
})
})

View File

@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import CanvasModeSelector from '@/components/graph/CanvasModeSelector.vue'
@@ -45,9 +44,8 @@ const i18n = createI18n({
const mockPopoverHide = vi.fn()
function renderComponent() {
const user = userEvent.setup()
render(CanvasModeSelector, {
function createWrapper() {
return mount(CanvasModeSelector, {
global: {
plugins: [i18n],
stubs: {
@@ -61,98 +59,94 @@ function renderComponent() {
}
}
})
return { user }
}
describe('CanvasModeSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render menu with menuitemradio roles and aria-checked', () => {
renderComponent()
const wrapper = createWrapper()
expect(screen.getByRole('menu')).toBeInTheDocument()
const menu = wrapper.find('[role="menu"]')
expect(menu.exists()).toBe(true)
const menuItems = screen.getAllByRole('menuitemradio')
const menuItems = wrapper.findAll('[role="menuitemradio"]')
expect(menuItems).toHaveLength(2)
expect(menuItems[0]).toHaveAttribute('aria-checked', 'true')
expect(menuItems[1]).toHaveAttribute('aria-checked', 'false')
// Select mode is active (read_only: false), so select is checked
expect(menuItems[0].attributes('aria-checked')).toBe('true')
expect(menuItems[1].attributes('aria-checked')).toBe('false')
})
it('should render menu items as buttons with aria-labels', () => {
renderComponent()
const wrapper = createWrapper()
const menuItems = screen.getAllByRole('menuitemradio')
menuItems.forEach((item) => {
expect(item.tagName).toBe('BUTTON')
expect(item).toHaveAttribute('type', 'button')
const menuItems = wrapper.findAll('[role="menuitemradio"]')
menuItems.forEach((btn) => {
expect(btn.element.tagName).toBe('BUTTON')
expect(btn.attributes('type')).toBe('button')
})
expect(menuItems[0]).toHaveAttribute('aria-label', 'Select')
expect(menuItems[1]).toHaveAttribute('aria-label', 'Hand')
expect(menuItems[0].attributes('aria-label')).toBe('Select')
expect(menuItems[1].attributes('aria-label')).toBe('Hand')
})
it('should use roving tabindex based on active mode', () => {
renderComponent()
const wrapper = createWrapper()
const menuItems = screen.getAllByRole('menuitemradio')
expect(menuItems[0]).toHaveAttribute('tabindex', '0')
expect(menuItems[1]).toHaveAttribute('tabindex', '-1')
const menuItems = wrapper.findAll('[role="menuitemradio"]')
// Select is active (read_only: false) → tabindex 0
expect(menuItems[0].attributes('tabindex')).toBe('0')
// Hand is inactive → tabindex -1
expect(menuItems[1].attributes('tabindex')).toBe('-1')
})
it('should mark icons as aria-hidden', () => {
renderComponent()
const wrapper = createWrapper()
const menuItems = screen.getAllByRole('menuitemradio')
menuItems.forEach((item) => {
// eslint-disable-next-line testing-library/no-node-access
const icons = item.querySelectorAll('i')
icons.forEach((icon) => {
expect(icon).toHaveAttribute('aria-hidden', 'true')
})
const icons = wrapper.findAll('[role="menuitemradio"] i')
icons.forEach((icon) => {
expect(icon.attributes('aria-hidden')).toBe('true')
})
})
it('should expose trigger button with aria-haspopup and aria-expanded', () => {
renderComponent()
const wrapper = createWrapper()
const trigger = screen.getByRole('button', { name: 'Canvas Mode' })
expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
const trigger = wrapper.find('[aria-haspopup="menu"]')
expect(trigger.exists()).toBe(true)
expect(trigger.attributes('aria-label')).toBe('Canvas Mode')
expect(trigger.attributes('aria-expanded')).toBe('false')
})
it('should call focus on next item when ArrowDown is pressed', async () => {
const { user } = renderComponent()
const wrapper = createWrapper()
const menuItems = screen.getAllByRole('menuitemradio')
const focusSpy = vi.spyOn(menuItems[1], 'focus')
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const secondItemEl = menuItems[1].element as HTMLElement
const focusSpy = vi.spyOn(secondItemEl, 'focus')
menuItems[0].focus()
await user.keyboard('{ArrowDown}')
await menuItems[0].trigger('keydown', { key: 'ArrowDown' })
expect(focusSpy).toHaveBeenCalled()
})
it('should call focus on previous item when ArrowUp is pressed', async () => {
const { user } = renderComponent()
const wrapper = createWrapper()
const menuItems = screen.getAllByRole('menuitemradio')
const focusSpy = vi.spyOn(menuItems[0], 'focus')
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const firstItemEl = menuItems[0].element as HTMLElement
const focusSpy = vi.spyOn(firstItemEl, 'focus')
menuItems[1].focus()
await user.keyboard('{ArrowUp}')
await menuItems[1].trigger('keydown', { key: 'ArrowUp' })
expect(focusSpy).toHaveBeenCalled()
})
it('should close popover on Escape and restore focus to trigger', async () => {
const { user } = renderComponent()
const wrapper = createWrapper()
const menuItems = screen.getAllByRole('menuitemradio')
const trigger = screen.getByRole('button', { name: 'Canvas Mode' })
const focusSpy = vi.spyOn(trigger, 'focus')
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const trigger = wrapper.find('[aria-haspopup="menu"]')
const triggerEl = trigger.element as HTMLElement
const focusSpy = vi.spyOn(triggerEl, 'focus')
menuItems[0].focus()
await user.keyboard('{Escape}')
await menuItems[0].trigger('keydown', { key: 'Escape' })
expect(mockPopoverHide).toHaveBeenCalled()
expect(focusSpy).toHaveBeenCalled()
})

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { render } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -89,7 +89,7 @@ describe('DomWidgets transition grace characterization', () => {
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
render(DomWidgets, {
mount(DomWidgets, {
global: {
stubs: {
DomWidget: true
@@ -134,7 +134,7 @@ describe('DomWidgets transition grace characterization', () => {
const canvas = createCanvas(graphB)
canvasStore.canvas = canvas
render(DomWidgets, {
mount(DomWidgets, {
global: {
stubs: {
DomWidget: true
@@ -160,7 +160,7 @@ describe('DomWidgets transition grace characterization', () => {
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
render(DomWidgets, {
mount(DomWidgets, {
global: {
stubs: {
DomWidget: true

View File

@@ -1,37 +1,19 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ZoomControlsModal from '@/components/graph/modals/ZoomControlsModal.vue'
// Mock functions
const mockExecute = vi.fn()
const mockGetCommand = vi.fn().mockImplementation((commandId: string) => ({
const mockGetCommand = vi.fn().mockReturnValue({
keybinding: {
combo: {
getKeySequences: () => [
'Ctrl',
commandId === 'Comfy.Canvas.ZoomIn'
? '+'
: commandId === 'Comfy.Canvas.ZoomOut'
? '-'
: '0'
]
getKeySequences: () => ['Ctrl', '+']
}
}
}))
const mockFormatKeySequence = vi
.fn()
.mockImplementation(
(command: {
keybinding: { combo: { getKeySequences: () => string[] } }
}) => {
const seq = command.keybinding.combo.getKeySequences()
if (seq.includes('+')) return 'Ctrl+'
if (seq.includes('-')) return 'Ctrl-'
return 'Ctrl+0'
}
)
})
const mockFormatKeySequence = vi.fn().mockReturnValue('Ctrl+')
const mockSetAppZoom = vi.fn()
const mockSettingGet = vi.fn().mockReturnValue(true)
@@ -41,11 +23,11 @@ const i18n = createI18n({
messages: { en: {} }
})
// Mock dependencies
vi.mock('@/renderer/extensions/minimap/composables/useMinimap', () => ({
useMinimap: () => ({
containerStyles: {
value: { backgroundColor: '#fff', borderRadius: '8px' }
}
containerStyles: { value: { backgroundColor: '#fff', borderRadius: '8px' } }
})
}))
@@ -70,8 +52,8 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
function renderComponent(props = {}) {
return render(ZoomControlsModal, {
const createWrapper = (props = {}) => {
return mount(ZoomControlsModal, {
props: {
visible: true,
...props
@@ -88,89 +70,90 @@ function renderComponent(props = {}) {
describe('ZoomControlsModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetAllMocks()
})
it('should execute zoom in command when zoom in button is clicked', async () => {
const user = userEvent.setup()
renderComponent()
const wrapper = createWrapper()
const zoomInButton = screen.getByTestId('zoom-in-action')
await user.click(zoomInButton)
const buttons = wrapper.findAll('.cursor-pointer')
const zoomInButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomIn')
)
expect(zoomInButton).toBeDefined()
await zoomInButton!.trigger('mousedown')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
})
it('should execute zoom out command when zoom out button is clicked', async () => {
const user = userEvent.setup()
renderComponent()
const wrapper = createWrapper()
const zoomOutButton = screen.getByTestId('zoom-out-action')
await user.click(zoomOutButton)
const buttons = wrapper.findAll('.cursor-pointer')
const zoomOutButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomOut')
)
expect(zoomOutButton).toBeDefined()
await zoomOutButton!.trigger('mousedown')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomOut')
})
it('should execute fit view command when fit view button is clicked', async () => {
const user = userEvent.setup()
renderComponent()
const wrapper = createWrapper()
const fitViewButton = screen.getByTestId('zoom-to-fit-action')
await user.click(fitViewButton)
const buttons = wrapper.findAll('.cursor-pointer')
const fitViewButton = buttons.find((btn) =>
btn.text().includes('zoomControls.zoomToFit')
)
expect(fitViewButton).toBeDefined()
await fitViewButton!.trigger('click')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
it('should call setAppZoomFromPercentage with valid zoom input values', async () => {
const user = userEvent.setup()
renderComponent()
const wrapper = createWrapper()
const input = screen.getByRole('spinbutton')
await user.tripleClick(input)
await user.keyboard('150')
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
expect(inputNumber.exists()).toBe(true)
// Emit the input event with PrimeVue's InputNumberInputEvent structure
await inputNumber.vm.$emit('input', { value: 150 })
expect(mockSetAppZoom).toHaveBeenCalledWith(150)
})
it('should not call setAppZoomFromPercentage when value is below minimum', async () => {
const user = userEvent.setup()
renderComponent()
it('should not call setAppZoomFromPercentage with invalid zoom input values', async () => {
const wrapper = createWrapper()
const input = screen.getByRole('spinbutton')
await user.tripleClick(input)
await user.keyboard('0')
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
expect(inputNumber.exists()).toBe(true)
// Test out of range values
await inputNumber.vm.$emit('input', { value: 0 })
expect(mockSetAppZoom).not.toHaveBeenCalled()
})
it('should not apply zoom values exceeding the maximum', async () => {
const user = userEvent.setup()
renderComponent()
const input = screen.getByRole('spinbutton')
await user.tripleClick(input)
await user.keyboard('100')
mockSetAppZoom.mockClear()
await user.keyboard('1')
await inputNumber.vm.$emit('input', { value: 1001 })
expect(mockSetAppZoom).not.toHaveBeenCalled()
})
it('should display keyboard shortcuts for commands', () => {
renderComponent()
const wrapper = createWrapper()
expect(screen.getByText('Ctrl+')).toBeInTheDocument()
expect(screen.getByText('Ctrl-')).toBeInTheDocument()
expect(screen.getByText('Ctrl+0')).toBeInTheDocument()
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.ZoomOut')
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.FitView')
const buttons = wrapper.findAll('.cursor-pointer')
expect(buttons.length).toBeGreaterThan(0)
// Each command button should show the keyboard shortcut
expect(mockFormatKeySequence).toHaveBeenCalled()
})
it('should not be visible when visible prop is false', () => {
renderComponent({ visible: false })
const wrapper = createWrapper({ visible: false })
expect(screen.queryByTestId('zoom-in-action')).toBeNull()
expect(wrapper.find('.absolute').exists()).toBe(false)
})
})

View File

@@ -1,6 +1,5 @@
import type { Mock } from 'vitest'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
@@ -106,10 +105,8 @@ describe('ColorPickerButton', () => {
workflowStore.activeWorkflow = createMockWorkflow()
})
function renderComponent() {
const user = userEvent.setup()
render(ColorPickerButton, {
const createWrapper = () => {
return mount(ColorPickerButton, {
global: {
plugins: [PrimeVue, i18n],
directives: {
@@ -117,30 +114,28 @@ describe('ColorPickerButton', () => {
}
}
})
return { user }
}
it('should render when nodes are selected', () => {
// Add a mock node to selectedItems
canvasStore.selectedItems = [createMockPositionable()]
renderComponent()
expect(screen.getByTestId('color-picker-button')).toBeInTheDocument()
const wrapper = createWrapper()
expect(wrapper.find('button').exists()).toBe(true)
})
it('should toggle color picker visibility on button click', async () => {
canvasStore.selectedItems = [createMockPositionable()]
const { user } = renderComponent()
const button = screen.getByTestId('color-picker-button')
const wrapper = createWrapper()
const button = wrapper.find('button')
expect(screen.queryByTestId('noColor')).not.toBeInTheDocument()
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
await user.click(button)
expect(screen.getByTestId('noColor')).toBeInTheDocument()
expect(screen.getByTestId('red')).toBeInTheDocument()
expect(screen.getByTestId('green')).toBeInTheDocument()
expect(screen.getByTestId('blue')).toBeInTheDocument()
await button.trigger('click')
const picker = wrapper.findComponent({ name: 'SelectButton' })
expect(picker.exists()).toBe(true)
expect(picker.findAll('button').length).toBeGreaterThan(0)
await user.click(button)
expect(screen.queryByTestId('noColor')).not.toBeInTheDocument()
await button.trigger('click')
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
})
})

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
@@ -13,6 +12,7 @@ import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
// Mock the utils
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn((node) => !!node?.type)
}))
@@ -21,6 +21,7 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
isOutputNode: vi.fn((node) => !!node?.constructor?.nodeData?.output_node)
}))
// Mock the composables
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: vi.fn(() => ({
selectedNodes: {
@@ -48,12 +49,14 @@ describe('ExecuteButton', () => {
})
beforeEach(() => {
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
createSpy: vi.fn
})
)
// Reset mocks
const partialCanvas: Partial<LGraphCanvas> = {
setDirty: vi.fn()
}
@@ -61,12 +64,14 @@ describe('ExecuteButton', () => {
mockSelectedNodes = []
// Get store instances and mock methods
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
vi.spyOn(commandStore, 'execute').mockResolvedValue()
// Update the useSelectionState mock
vi.mocked(useSelectionState).mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
@@ -76,33 +81,33 @@ describe('ExecuteButton', () => {
vi.clearAllMocks()
})
const renderComponent = () => {
return render(ExecuteButton, {
const mountComponent = () => {
return mount(ExecuteButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip }
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:play': { template: '<div class="play-icon" />' }
}
}
})
}
describe('Rendering', () => {
it('should be able to render', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Execute selected nodes' })
).toBeTruthy()
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
})
describe('Click Handler', () => {
it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => {
const commandStore = useCommandStore()
const user = userEvent.setup()
renderComponent()
const wrapper = mountComponent()
const button = wrapper.find('button')
await user.click(
screen.getByRole('button', { name: 'Execute selected nodes' })
)
await button.trigger('click')
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.QueueSelectedOutputNodes'

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
@@ -19,12 +18,6 @@ vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: vi.fn()
})
}))
describe('InfoButton', () => {
const i18n = createI18n({
legacy: false,
@@ -43,8 +36,8 @@ describe('InfoButton', () => {
vi.clearAllMocks()
})
const renderComponent = () => {
return render(InfoButton, {
const mountComponent = () => {
return mount(InfoButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
@@ -54,11 +47,9 @@ describe('InfoButton', () => {
}
it('should open the info panel on click', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Node Info' }))
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="info-button"]')
await button.trigger('click')
expect(openPanelMock).toHaveBeenCalledWith('info')
})
})

View File

@@ -1,9 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromPartial } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { reactive } from 'vue'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
@@ -100,17 +100,16 @@ describe('DomWidget disabled style', () => {
it('uses disabled style when promoted override widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const { container } = render(DomWidget, {
const wrapper = mount(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await nextTick()
await wrapper.vm.$nextTick()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const root = container.querySelector('.dom-widget') as HTMLElement
const root = wrapper.get('.dom-widget').element as HTMLElement
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
@@ -11,11 +11,10 @@ describe('HoneyToast', () => {
document.body.innerHTML = ''
})
function renderComponent(
function mountComponent(
props: { visible: boolean; expanded?: boolean } = { visible: true }
) {
const user = userEvent.setup()
const { unmount } = render(HoneyToast, {
): VueWrapper {
return mount(HoneyToast, {
props,
slots: {
default: (slotProps: { isExpanded: boolean }) =>
@@ -34,45 +33,48 @@ describe('HoneyToast', () => {
slotProps.isExpanded ? 'Collapse' : 'Expand'
)
},
container: document.body.appendChild(document.createElement('div'))
attachTo: document.body
})
return { user, unmount }
}
it('renders when visible is true', async () => {
const { unmount } = renderComponent({ visible: true })
const wrapper = mountComponent({ visible: true })
await nextTick()
expect(screen.getByRole('status')).toBeInTheDocument()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeTruthy()
unmount()
wrapper.unmount()
})
it('does not render when visible is false', async () => {
const { unmount } = renderComponent({ visible: false })
const wrapper = mountComponent({ visible: false })
await nextTick()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeFalsy()
unmount()
wrapper.unmount()
})
it('passes is-expanded=false to slots by default', async () => {
const { unmount } = renderComponent({ visible: true })
const wrapper = mountComponent({ visible: true })
await nextTick()
expect(screen.getByTestId('content')).toHaveTextContent('collapsed')
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
unmount()
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const { unmount } = renderComponent({ visible: true })
const wrapper = mountComponent({ visible: true })
await nextTick()
expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite')
const toast = document.body.querySelector('[role="status"]')
expect(toast?.getAttribute('aria-live')).toBe('polite')
unmount()
wrapper.unmount()
})
it('supports v-model:expanded with reactive parent state', async () => {
@@ -96,21 +98,23 @@ describe('HoneyToast', () => {
`
})
const user = userEvent.setup()
const { unmount } = render(TestWrapper, {
container: document.body.appendChild(document.createElement('div'))
})
const wrapper = mount(TestWrapper, { attachTo: document.body })
await nextTick()
expect(screen.getByTestId('content')).toHaveTextContent('collapsed')
expect(screen.getByTestId('toggle-btn')).toHaveTextContent('Expand')
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
await user.click(screen.getByTestId('toggle-btn'))
const toggleBtn = document.body.querySelector(
'[data-testid="toggle-btn"]'
) as HTMLButtonElement
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
toggleBtn?.click()
await nextTick()
expect(screen.getByTestId('content')).toHaveTextContent('expanded')
expect(screen.getByTestId('toggle-btn')).toHaveTextContent('Collapse')
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
unmount()
wrapper.unmount()
})
})

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -28,7 +27,7 @@ const options = [
{ name: 'Option C', value: 'c' }
]
function renderInParent(
function mountInParent(
multiSelectProps: Record<string, unknown> = {},
modelValue: { name: string; value: string }[] = []
) {
@@ -50,12 +49,12 @@ function renderInParent(
}
}
const { unmount } = render(Parent, {
container: document.body.appendChild(document.createElement('div')),
const wrapper = mount(Parent, {
attachTo: document.body,
global: { plugins: [i18n] }
})
return { unmount, parentEscapeCount }
return { wrapper, parentEscapeCount }
}
function dispatchEscape(element: Element) {
@@ -74,32 +73,30 @@ function findContentElement(): HTMLElement | null {
describe('MultiSelect', () => {
it('keeps open-state border styling available while the dropdown is open', async () => {
const user = userEvent.setup()
const { unmount } = renderInParent()
const { wrapper } = mountInParent()
const trigger = screen.getByRole('button')
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
expect(trigger).toHaveClass(
expect(trigger.classes()).toContain(
'data-[state=open]:border-node-component-border'
)
expect(trigger).toHaveAttribute('aria-expanded', 'false')
expect(trigger.attributes('aria-expanded')).toBe('false')
await user.click(trigger)
await trigger.trigger('click')
await nextTick()
expect(trigger).toHaveAttribute('aria-expanded', 'true')
expect(trigger).toHaveAttribute('data-state', 'open')
expect(trigger.attributes('aria-expanded')).toBe('true')
expect(trigger.attributes('data-state')).toBe('open')
unmount()
wrapper.unmount()
})
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const user = userEvent.setup()
const { unmount, parentEscapeCount } = renderInParent()
const { wrapper, parentEscapeCount } = mountInParent()
const trigger = screen.getByRole('button')
await user.click(trigger)
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
await trigger.trigger('click')
await nextTick()
const content = findContentElement()
@@ -110,46 +107,48 @@ describe('MultiSelect', () => {
expect(parentEscapeCount.value).toBe(0)
unmount()
wrapper.unmount()
})
it('closes the popover when Escape is pressed', async () => {
const user = userEvent.setup()
const { unmount } = renderInParent()
const { wrapper } = mountInParent()
const trigger = screen.getByRole('button')
await user.click(trigger)
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
await trigger.trigger('click')
await nextTick()
expect(trigger).toHaveAttribute('data-state', 'open')
expect(trigger.attributes('data-state')).toBe('open')
const content = findContentElement()
dispatchEscape(content!)
await nextTick()
expect(trigger).toHaveAttribute('data-state', 'closed')
expect(trigger.attributes('data-state')).toBe('closed')
unmount()
wrapper.unmount()
})
})
describe('selected count badge', () => {
it('shows selected count when items are selected', () => {
const { unmount } = renderInParent({}, [
const { wrapper } = mountInParent({}, [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' }
])
expect(screen.getByText('2')).toBeInTheDocument()
expect(wrapper.text()).toContain('2')
unmount()
wrapper.unmount()
})
it('does not show count badge when no items are selected', () => {
const { unmount } = renderInParent()
const { wrapper } = mountInParent()
const multiSelect = wrapper.findComponent(MultiSelect)
const spans = multiSelect.findAll('span')
const countBadge = spans.find((s) => /^\d+$/.test(s.text().trim()))
expect(screen.queryByText(/^\d+$/)).not.toBeInTheDocument()
expect(countBadge).toBeUndefined()
unmount()
wrapper.unmount()
})
})
})

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -37,7 +37,7 @@ function findContentElement(): HTMLElement | null {
return document.querySelector('[data-dismissable-layer]')
}
function renderInParent(modelValue?: string) {
function mountInParent(modelValue?: string) {
const parentEscapeCount = { value: 0 }
const Parent = {
@@ -55,12 +55,12 @@ function renderInParent(modelValue?: string) {
}
}
const { unmount } = render(Parent, {
container: document.body.appendChild(document.createElement('div')),
const wrapper = mount(Parent, {
attachTo: document.body,
global: { plugins: [i18n] }
})
return { unmount, parentEscapeCount }
return { wrapper, parentEscapeCount }
}
async function openSelect(triggerEl: HTMLElement) {
@@ -81,10 +81,10 @@ async function openSelect(triggerEl: HTMLElement) {
describe('SingleSelect', () => {
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const { unmount, parentEscapeCount } = renderInParent()
const { wrapper, parentEscapeCount } = mountInParent()
const trigger = screen.getByRole('combobox')
await openSelect(trigger)
const trigger = wrapper.find('button[role="combobox"]')
await openSelect(trigger.element as HTMLElement)
const content = findContentElement()
expect(content).not.toBeNull()
@@ -94,23 +94,23 @@ describe('SingleSelect', () => {
expect(parentEscapeCount.value).toBe(0)
unmount()
wrapper.unmount()
})
it('closes the popover when Escape is pressed', async () => {
const { unmount } = renderInParent()
const { wrapper } = mountInParent()
const trigger = screen.getByRole('combobox')
await openSelect(trigger)
expect(trigger).toHaveAttribute('data-state', 'open')
const trigger = wrapper.find('button[role="combobox"]')
await openSelect(trigger.element as HTMLElement)
expect(trigger.attributes('data-state')).toBe('open')
const content = findContentElement()
dispatchEscape(content!)
await nextTick()
expect(trigger).toHaveAttribute('data-state', 'closed')
expect(trigger.attributes('data-state')).toBe('closed')
unmount()
wrapper.unmount()
})
})
})

View File

@@ -1,11 +1,10 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import * as markdownRendererUtil from '@/utils/markdownRendererUtil'
@@ -55,11 +54,13 @@ describe('NodePreview', () => {
description: 'Test node description'
}
function renderComponent(nodeDef: ComfyNodeDefV2 = mockNodeDef) {
return render(NodePreview, {
const mountComponent = (nodeDef: ComfyNodeDefV2 = mockNodeDef) => {
return mount(NodePreview, {
global: {
plugins: [PrimeVue, i18n, pinia],
stubs: {}
stubs: {
// Stub stores if needed
}
},
props: {
nodeDef
@@ -68,18 +69,18 @@ describe('NodePreview', () => {
}
it('renders node preview with correct structure', () => {
renderComponent()
const wrapper = mountComponent()
expect(screen.getByTestId('node-preview')).toBeInTheDocument()
expect(screen.getByTestId('node-header')).toBeInTheDocument()
expect(screen.getByText('Preview')).toBeInTheDocument()
expect(wrapper.find('._sb_node_preview').exists()).toBe(true)
expect(wrapper.find('.node_header').exists()).toBe(true)
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
})
it('sets title attribute on node header with full display name', () => {
renderComponent()
const nodeHeader = screen.getByTestId('node-header')
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader).toHaveAttribute('title', mockNodeDef.display_name)
expect(nodeHeader.attributes('title')).toBe(mockNodeDef.display_name)
})
it('displays truncated long node names with ellipsis', () => {
@@ -89,11 +90,17 @@ describe('NodePreview', () => {
'This Is An Extremely Long Node Name That Should Definitely Be Truncated With Ellipsis To Prevent Layout Issues'
}
renderComponent(longNameNodeDef)
const nodeHeader = screen.getByTestId('node-header')
const wrapper = mountComponent(longNameNodeDef)
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader).toHaveAttribute('title', longNameNodeDef.display_name)
expect(nodeHeader).toHaveTextContent(longNameNodeDef.display_name!)
// Verify the title attribute contains the full name
expect(nodeHeader.attributes('title')).toBe(longNameNodeDef.display_name)
// Verify overflow handling classes are applied
expect(nodeHeader.classes()).toContain('text-ellipsis')
// The actual text content should still be the full name (CSS handles truncation)
expect(nodeHeader.text()).toContain(longNameNodeDef.display_name)
})
it('handles short node names without issues', () => {
@@ -102,18 +109,18 @@ describe('NodePreview', () => {
display_name: 'Short'
}
renderComponent(shortNameNodeDef)
const nodeHeader = screen.getByTestId('node-header')
const wrapper = mountComponent(shortNameNodeDef)
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader).toHaveAttribute('title', 'Short')
expect(nodeHeader).toHaveTextContent('Short')
expect(nodeHeader.attributes('title')).toBe('Short')
expect(nodeHeader.text()).toContain('Short')
})
it('applies proper spacing to the dot element', () => {
renderComponent()
const headdot = screen.getByTestId('head-dot')
const wrapper = mountComponent()
const headdot = wrapper.find('.headdot')
expect(headdot).toBeInTheDocument()
expect(headdot.classes()).toContain('pr-3')
})
describe('Description Rendering', () => {
@@ -123,13 +130,11 @@ describe('NodePreview', () => {
description: 'This is a plain text description'
}
renderComponent(plainTextNodeDef)
const description = screen.getByTestId('node-description')
const wrapper = mountComponent(plainTextNodeDef)
const description = wrapper.find('._sb_description')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain(
'This is a plain text description'
)
expect(description.exists()).toBe(true)
expect(description.html()).toContain('This is a plain text description')
})
it('renders markdown description with formatting', () => {
@@ -138,13 +143,13 @@ describe('NodePreview', () => {
description: '**Bold text** and *italic text* with `code`'
}
renderComponent(markdownNodeDef)
const description = screen.getByTestId('node-description')
const wrapper = mountComponent(markdownNodeDef)
const description = wrapper.find('._sb_description')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain('<strong>Bold text</strong>')
expect(description.innerHTML).toContain('<em>italic text</em>')
expect(description.innerHTML).toContain('<code>code</code>')
expect(description.exists()).toBe(true)
expect(description.html()).toContain('<strong>Bold text</strong>')
expect(description.html()).toContain('<em>italic text</em>')
expect(description.html()).toContain('<code>code</code>')
})
it('does not render description element when description is empty', () => {
@@ -153,16 +158,20 @@ describe('NodePreview', () => {
description: ''
}
renderComponent(noDescriptionNodeDef)
const wrapper = mountComponent(noDescriptionNodeDef)
const description = wrapper.find('._sb_description')
expect(screen.queryByTestId('node-description')).not.toBeInTheDocument()
expect(description.exists()).toBe(false)
})
it('does not render description element when description is undefined', () => {
const { description, ...nodeDefWithoutDescription } = mockNodeDef
renderComponent(nodeDefWithoutDescription as ComfyNodeDefV2)
const wrapper = mountComponent(
nodeDefWithoutDescription as ComfyNodeDefV2
)
const descriptionElement = wrapper.find('._sb_description')
expect(screen.queryByTestId('node-description')).not.toBeInTheDocument()
expect(descriptionElement.exists()).toBe(false)
})
it('calls renderMarkdownToHtml utility function', () => {
@@ -174,7 +183,7 @@ describe('NodePreview', () => {
description: testDescription
}
renderComponent(nodeDefWithDescription)
mountComponent(nodeDefWithDescription)
expect(spy).toHaveBeenCalledWith(testDescription)
spy.mockRestore()
@@ -187,13 +196,21 @@ describe('NodePreview', () => {
'Safe **markdown** content <script>alert("xss")</script> with `code` blocks'
}
renderComponent(unsafeNodeDef)
const description = screen.getByTestId('node-description')
const wrapper = mountComponent(unsafeNodeDef)
const description = wrapper.find('._sb_description')
expect(description.innerHTML).not.toContain('<script>')
expect(description.innerHTML).not.toContain('alert("xss")')
expect(description.innerHTML).toContain('<strong>markdown</strong>')
expect(description.innerHTML).toContain('<code>code</code>')
// The description should still exist because there's safe content
if (description.exists()) {
// Should not contain script tags (sanitized by DOMPurify)
expect(description.html()).not.toContain('<script>')
expect(description.html()).not.toContain('alert("xss")')
// Should contain the safe markdown content rendered as HTML
expect(description.html()).toContain('<strong>markdown</strong>')
expect(description.html()).toContain('<code>code</code>')
} else {
// If DOMPurify removes everything, that's also acceptable for security
expect(description.exists()).toBe(false)
}
})
it('handles markdown with line breaks', () => {
@@ -202,11 +219,12 @@ describe('NodePreview', () => {
description: 'Line 1\n\nLine 3 after empty line'
}
renderComponent(multilineNodeDef)
const description = screen.getByTestId('node-description')
const wrapper = mountComponent(multilineNodeDef)
const description = wrapper.find('._sb_description')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain('<p>')
expect(description.exists()).toBe(true)
// Should contain paragraph tags for proper line break handling
expect(description.html()).toContain('<p>')
})
it('handles markdown lists', () => {
@@ -215,19 +233,19 @@ describe('NodePreview', () => {
description: '- Item 1\n- Item 2\n- Item 3'
}
renderComponent(listNodeDef)
const description = screen.getByTestId('node-description')
const wrapper = mountComponent(listNodeDef)
const description = wrapper.find('._sb_description')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain('<ul>')
expect(description.innerHTML).toContain('<li>')
expect(description.exists()).toBe(true)
expect(description.html()).toContain('<ul>')
expect(description.html()).toContain('<li>')
})
it('renders description element', () => {
renderComponent()
const description = screen.getByTestId('node-description')
it('applies correct styling classes to description', () => {
const wrapper = mountComponent()
const description = wrapper.find('._sb_description')
expect(description).toBeInTheDocument()
expect(description.classes()).toContain('_sb_description')
})
it('uses v-html directive for rendered content', () => {
@@ -236,11 +254,12 @@ describe('NodePreview', () => {
description: 'Content with **bold** text'
}
renderComponent(htmlNodeDef)
const description = screen.getByTestId('node-description')
const wrapper = mountComponent(htmlNodeDef)
const description = wrapper.find('._sb_description')
expect(description.innerHTML).toContain('<strong>bold</strong>')
expect(description.innerHTML).not.toContain('&lt;strong&gt;')
// The component should render the HTML, not escape it
expect(description.html()).toContain('<strong>bold</strong>')
expect(description.html()).not.toContain('&lt;strong&gt;')
})
it('prevents XSS attacks by sanitizing dangerous HTML elements', () => {
@@ -250,12 +269,17 @@ describe('NodePreview', () => {
'Normal text <img src="x" onerror="alert(\'XSS\')" /> and **bold** text'
}
renderComponent(maliciousNodeDef)
const description = screen.getByTestId('node-description')
const wrapper = mountComponent(maliciousNodeDef)
const description = wrapper.find('._sb_description')
expect(description.innerHTML).not.toContain('onerror')
expect(description.innerHTML).not.toContain('alert(')
expect(description.innerHTML).toContain('<strong>bold</strong>')
if (description.exists()) {
// Should not contain dangerous event handlers
expect(description.html()).not.toContain('onerror')
expect(description.html()).not.toContain('alert(')
// Should still contain safe markdown content
expect(description.html()).toContain('<strong>bold</strong>')
// May or may not contain img tag depending on DOMPurify config
}
})
})
})

View File

@@ -7,22 +7,17 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
:node-def="nodeDef"
:position="position"
/>
<div
v-else
class="_sb_node_preview bg-component-node-background"
data-testid="node-preview"
>
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
class="node_header text-ellipsis"
data-testid="node-header"
:title="nodeDef.display_name"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
color: litegraphColors.NODE_TITLE_COLOR
}"
>
<div class="_sb_dot headdot pr-3" data-testid="head-dot" />
<div class="_sb_dot headdot pr-3" />
{{ nodeDef.display_name }}
</div>
<div class="_sb_preview_badge">{{ $t('g.preview') }}</div>
@@ -81,7 +76,6 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div
v-if="renderedDescription"
class="_sb_description"
data-testid="node-description"
:style="{
color: litegraphColors.WIDGET_SECONDARY_TEXT_COLOR,
backgroundColor: litegraphColors.WIDGET_BGCOLOR

View File

@@ -1,9 +1,9 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { i18n } from '@/i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
const popoverCloseSpy = vi.fn()
@@ -52,10 +52,8 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
const renderMenu = () =>
render(JobHistoryActionsMenu, {
const mountMenu = () =>
mount(JobHistoryActionsMenu, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
@@ -77,11 +75,12 @@ describe('JobHistoryActionsMenu', () => {
})
it('toggles show run progress bar setting from the menu', async () => {
const user = userEvent.setup()
const wrapper = mountMenu()
renderMenu()
await user.click(screen.getByTestId('show-run-progress-bar-action'))
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(
@@ -91,16 +90,17 @@ describe('JobHistoryActionsMenu', () => {
})
it('opens docked job history sidebar when enabling from the menu', async () => {
const user = userEvent.setup()
mockGetSetting.mockImplementation((key: string) => {
if (key === 'Comfy.Queue.QPOV2') return false
if (key === 'Comfy.Queue.ShowRunProgressBar') return true
return undefined
})
const wrapper = mountMenu()
renderMenu()
await user.click(screen.getByTestId('docked-job-history-action'))
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledTimes(1)
@@ -110,20 +110,14 @@ describe('JobHistoryActionsMenu', () => {
})
it('emits clear history from the menu', async () => {
const user = userEvent.setup()
const clearHistorySpy = vi.fn()
const wrapper = mountMenu()
render(JobHistoryActionsMenu, {
props: { onClearHistory: clearHistorySpy },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
await user.click(screen.getByTestId('clear-history-action'))
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
await clearHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(clearHistorySpy).toHaveBeenCalledOnce()
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
})

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -33,20 +32,30 @@ const tooltipDirectiveStub = {
updated: vi.fn()
}
const defaultProps = {
totalProgressStyle: { width: '65%' },
currentNodeProgressStyle: { width: '40%' },
totalPercentFormatted: '65%',
currentNodePercentFormatted: '40%',
currentNodeName: 'Sampler',
runningCount: 1,
queuedCount: 2,
bottomRowClass: 'flex custom-bottom-row'
const SELECTORS = {
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
clearQueuedButton: 'button[aria-label="Clear queued"]',
summaryRow: '.flex.items-center.gap-2',
currentNodeRow: '.flex.items-center.gap-1.text-text-secondary'
}
const renderComponent = (props: Record<string, unknown> = {}) =>
render(QueueOverlayActive, {
props: { ...defaultProps, ...props },
const COPY = {
viewAllJobs: 'View all jobs'
}
const mountComponent = (props: Record<string, unknown> = {}) =>
mount(QueueOverlayActive, {
props: {
totalProgressStyle: { width: '65%' },
currentNodeProgressStyle: { width: '40%' },
totalPercentFormatted: '65%',
currentNodePercentFormatted: '40%',
currentNodeName: 'Sampler',
runningCount: 1,
queuedCount: 2,
bottomRowClass: 'flex custom-bottom-row',
...props
},
global: {
plugins: [i18n],
directives: {
@@ -57,65 +66,58 @@ const renderComponent = (props: Record<string, unknown> = {}) =>
describe('QueueOverlayActive', () => {
it('renders progress metrics and emits actions when buttons clicked', async () => {
const user = userEvent.setup()
const interruptAllSpy = vi.fn()
const clearQueuedSpy = vi.fn()
const viewAllJobsSpy = vi.fn()
const wrapper = mountComponent({ runningCount: 2, queuedCount: 3 })
const { container } = renderComponent({
runningCount: 2,
queuedCount: 3,
onInterruptAll: interruptAllSpy,
onClearQueued: clearQueuedSpy,
onViewAllJobs: viewAllJobsSpy
})
const progressBars = wrapper.findAll('.absolute.inset-0')
expect(progressBars[0].attributes('style')).toContain('width: 65%')
expect(progressBars[1].attributes('style')).toContain('width: 40%')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const progressBars = container.querySelectorAll('.absolute.inset-0')
expect(progressBars[0]).toHaveStyle({ width: '65%' })
expect(progressBars[1]).toHaveStyle({ width: '40%' })
const content = wrapper.text().replace(/\s+/g, ' ')
expect(content).toContain('Total: 65%')
expect(screen.getByText('65%')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('running')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('queued')).toBeInTheDocument()
expect(screen.getByText('Current node:')).toBeInTheDocument()
expect(screen.getByText('Sampler')).toBeInTheDocument()
expect(screen.getByText('40%')).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'Interrupt all running jobs' })
const [runningSection, queuedSection] = wrapper.findAll(
SELECTORS.summaryRow
)
expect(interruptAllSpy).toHaveBeenCalledOnce()
expect(runningSection.text()).toContain('2')
expect(runningSection.text()).toContain('running')
expect(queuedSection.text()).toContain('3')
expect(queuedSection.text()).toContain('queued')
await user.click(screen.getByRole('button', { name: 'Clear queued' }))
expect(clearQueuedSpy).toHaveBeenCalledOnce()
const currentNodeSection = wrapper.find(SELECTORS.currentNodeRow)
expect(currentNodeSection.text()).toContain('Current node:')
expect(currentNodeSection.text()).toContain('Sampler')
expect(currentNodeSection.text()).toContain('40%')
await user.click(screen.getByRole('button', { name: 'View all jobs' }))
expect(viewAllJobsSpy).toHaveBeenCalledOnce()
const interruptButton = wrapper.get(SELECTORS.interruptAllButton)
await interruptButton.trigger('click')
expect(wrapper.emitted('interruptAll')).toHaveLength(1)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('.custom-bottom-row')).toBeTruthy()
const clearQueuedButton = wrapper.get(SELECTORS.clearQueuedButton)
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
const buttons = wrapper.findAll('button')
const viewAllButton = buttons.find((btn) =>
btn.text().includes(COPY.viewAllJobs)
)
expect(viewAllButton).toBeDefined()
await viewAllButton!.trigger('click')
expect(wrapper.emitted('viewAllJobs')).toHaveLength(1)
expect(wrapper.find('.custom-bottom-row').exists()).toBe(true)
})
it('hides action buttons when counts are zero', () => {
renderComponent({ runningCount: 0, queuedCount: 0 })
const wrapper = mountComponent({ runningCount: 0, queuedCount: 0 })
expect(
screen.queryByRole('button', { name: 'Interrupt all running jobs' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Clear queued' })
).not.toBeInTheDocument()
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
})
it('builds tooltip configs with translated strings', () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
renderComponent()
mountComponent()
expect(spy).toHaveBeenCalledWith('Cancel job')
expect(spy).toHaveBeenCalledWith('Clear queue')

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
@@ -56,8 +55,8 @@ const tooltipDirectiveStub = {
updated: vi.fn()
}
const renderHeader = (props = {}) =>
render(QueueOverlayHeader, {
const mountHeader = (props = {}) =>
mount(QueueOverlayHeader, {
props: {
headerTitle: 'Job queue',
queuedCount: 3,
@@ -82,53 +81,54 @@ describe('QueueOverlayHeader', () => {
})
it('renders header title', () => {
renderHeader()
expect(screen.getByText('Job queue')).toBeInTheDocument()
const wrapper = mountHeader()
expect(wrapper.text()).toContain('Job queue')
})
it('shows clear queue text and emits clear queued', async () => {
const user = userEvent.setup()
const clearQueuedSpy = vi.fn()
const wrapper = mountHeader({ queuedCount: 4 })
renderHeader({ queuedCount: 4, onClearQueued: clearQueuedSpy })
expect(wrapper.text()).toContain('Clear queue')
expect(wrapper.text()).not.toContain('4 queued')
expect(screen.getByText('Clear queue')).toBeInTheDocument()
expect(screen.queryByText('4 queued')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Clear queued' }))
expect(clearQueuedSpy).toHaveBeenCalledOnce()
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
})
it('disables clear queued button when queued count is zero', () => {
renderHeader({ queuedCount: 0 })
const wrapper = mountHeader({ queuedCount: 0 })
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
expect(screen.getByRole('button', { name: 'Clear queued' })).toBeDisabled()
expect(screen.getByText('Clear queue')).toBeInTheDocument()
expect(clearQueuedButton.attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('Clear queue')
})
it('emits clear history from the menu', async () => {
const user = userEvent.setup()
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
const clearHistorySpy = vi.fn()
renderHeader({ onClearHistory: clearHistorySpy })
const wrapper = mountHeader()
expect(
screen.getByRole('button', { name: 'More options' })
).toBeInTheDocument()
expect(wrapper.find('button[aria-label="More options"]').exists()).toBe(
true
)
expect(spy).toHaveBeenCalledWith('More')
await user.click(screen.getByTestId('clear-history-action'))
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
await clearHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(clearHistorySpy).toHaveBeenCalledOnce()
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
it('opens floating queue progress overlay when disabling from the menu', async () => {
const user = userEvent.setup()
const wrapper = mountHeader()
renderHeader()
await user.click(screen.getByTestId('docked-job-history-action'))
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledTimes(1)
@@ -141,14 +141,15 @@ describe('QueueOverlayHeader', () => {
})
it('opens docked job history sidebar when enabling from the menu', async () => {
const user = userEvent.setup()
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = mountHeader()
renderHeader()
await user.click(screen.getByTestId('docked-job-history-action'))
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledTimes(1)
@@ -158,15 +159,16 @@ describe('QueueOverlayHeader', () => {
})
it('keeps docked target open even when enabling persistence fails', async () => {
const user = userEvent.setup()
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
mockSetSetting.mockRejectedValueOnce(new Error('persistence failed'))
const wrapper = mountHeader()
renderHeader()
await user.click(screen.getByTestId('docked-job-history-action'))
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
@@ -174,12 +176,13 @@ describe('QueueOverlayHeader', () => {
})
it('closes the menu when disabling persistence fails', async () => {
const user = userEvent.setup()
mockSetMany.mockRejectedValueOnce(new Error('persistence failed'))
const wrapper = mountHeader()
renderHeader()
await user.click(screen.getByTestId('docked-job-history-action'))
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledWith({
@@ -189,11 +192,12 @@ describe('QueueOverlayHeader', () => {
})
it('toggles show run progress bar setting from the menu', async () => {
const user = userEvent.setup()
const wrapper = mountHeader()
renderHeader()
await user.click(screen.getByTestId('show-run-progress-bar-action'))
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(

View File

@@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { defineComponent, nextTick } from 'vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
@@ -29,6 +28,7 @@ const popoverStub = defineComponent({
this.hide()
return
}
this.show(event, target)
},
show(event: Event, target?: EventTarget | null) {
@@ -43,7 +43,7 @@ const popoverStub = defineComponent({
}
},
template: `
<div v-if="visible" ref="container" data-testid="popover">
<div v-if="visible" ref="container" class="popover-stub">
<slot />
</div>
`
@@ -51,18 +51,21 @@ const popoverStub = defineComponent({
const buttonStub = {
props: {
disabled: { type: Boolean, default: false },
ariaLabel: { type: String, default: undefined }
disabled: {
type: Boolean,
default: false
}
},
template: `
<button :disabled="disabled" :aria-label="ariaLabel">
<div
class="button-stub"
:data-disabled="String(disabled)"
>
<slot />
</button>
</div>
`
}
type MenuHandle = { open: (e: Event) => Promise<void>; hide: () => void }
const createEntries = (): MenuEntry[] => [
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
{
@@ -74,6 +77,17 @@ const createEntries = (): MenuEntry[] => [
{ kind: 'divider', key: 'divider-1' }
]
const mountComponent = (entries: MenuEntry[]) =>
mount(JobContextMenu, {
props: { entries },
global: {
stubs: {
Popover: popoverStub,
Button: buttonStub
}
}
})
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
({
type,
@@ -81,37 +95,13 @@ const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
target: currentTarget
}) as Event
function renderMenu(entries: MenuEntry[], onAction?: ReturnType<typeof vi.fn>) {
const menuRef = ref<MenuHandle | null>(null)
const Wrapper = {
components: { JobContextMenu },
setup() {
return { menuRef, entries }
},
template:
'<JobContextMenu ref="menuRef" :entries="entries" @action="$emit(\'action\', $event)" />'
}
const user = userEvent.setup()
const actionSpy = onAction ?? vi.fn()
const { unmount } = render(Wrapper, {
props: { onAction: actionSpy },
global: {
stubs: { Popover: popoverStub, Button: buttonStub }
}
})
return { user, menuRef, onAction: actionSpy, unmount }
}
async function openMenu(
menuRef: ReturnType<typeof ref<MenuHandle | null>>,
const openMenu = async (
wrapper: ReturnType<typeof mountComponent>,
type: string = 'click'
) {
) => {
const trigger = document.createElement('button')
document.body.append(trigger)
await menuRef.value!.open(createTriggerEvent(type, trigger))
await wrapper.vm.open(createTriggerEvent(type, trigger))
await nextTick()
return trigger
}
@@ -122,33 +112,31 @@ afterEach(() => {
describe('JobContextMenu', () => {
it('passes disabled state to action buttons', async () => {
const { menuRef, unmount } = renderMenu(createEntries())
await openMenu(menuRef)
const wrapper = mountComponent(createEntries())
await openMenu(wrapper)
const enabledBtn = screen.getByRole('button', { name: 'Enabled action' })
const disabledBtn = screen.getByRole('button', {
name: 'Disabled action'
})
expect(enabledBtn).not.toBeDisabled()
expect(disabledBtn).toBeDisabled()
const buttons = wrapper.findAll('.button-stub')
expect(buttons).toHaveLength(2)
expect(buttons[0].attributes('data-disabled')).toBe('false')
expect(buttons[1].attributes('data-disabled')).toBe('true')
unmount()
wrapper.unmount()
})
it('emits action for enabled entries', async () => {
const entries = createEntries()
const { user, menuRef, onAction, unmount } = renderMenu(entries)
await openMenu(menuRef)
const wrapper = mountComponent(entries)
await openMenu(wrapper)
await user.click(screen.getByRole('button', { name: 'Enabled action' }))
await wrapper.findAll('.button-stub')[0].trigger('click')
expect(onAction).toHaveBeenCalledWith(entries[0])
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
unmount()
wrapper.unmount()
})
it('does not emit action for disabled entries', async () => {
const { user, menuRef, onAction, unmount } = renderMenu([
const wrapper = mountComponent([
{
key: 'disabled',
label: 'Disabled action',
@@ -156,54 +144,52 @@ describe('JobContextMenu', () => {
onClick: vi.fn()
}
])
await openMenu(menuRef)
await openMenu(wrapper)
await user.click(screen.getByRole('button', { name: 'Disabled action' }))
await wrapper.get('.button-stub').trigger('click')
expect(onAction).not.toHaveBeenCalled()
expect(wrapper.emitted('action')).toBeUndefined()
unmount()
wrapper.unmount()
})
it('hides on pointerdown outside the popover', async () => {
const { menuRef, unmount } = renderMenu(createEntries())
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
const outside = document.createElement('div')
document.body.append(trigger, outside)
await menuRef.value!.open(createTriggerEvent('contextmenu', trigger))
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
await nextTick()
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.pointerDown(outside)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(screen.queryByTestId('popover')).not.toBeInTheDocument()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
unmount()
wrapper.unmount()
})
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
const { menuRef, unmount } = renderMenu(createEntries())
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
document.body.append(trigger)
await menuRef.value!.open(createTriggerEvent('click', trigger))
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.pointerDown(trigger)
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(screen.getByTestId('popover')).toBeInTheDocument()
await menuRef.value!.open(createTriggerEvent('click', trigger))
expect(wrapper.find('.popover-stub').exists()).toBe(true)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(screen.queryByTestId('popover')).not.toBeInTheDocument()
unmount()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
@@ -57,16 +56,12 @@ const i18n = createI18n({
describe('JobFiltersBar', () => {
it('emits showAssets when the assets icon button is clicked', async () => {
const user = userEvent.setup()
const showAssetsSpy = vi.fn()
render(JobFiltersBar, {
const wrapper = mount(JobFiltersBar, {
props: {
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
hasFailedJobs: false,
onShowAssets: showAssetsSpy
hasFailedJobs: false
},
global: {
plugins: [i18n],
@@ -74,13 +69,16 @@ describe('JobFiltersBar', () => {
}
})
await user.click(screen.getByRole('button', { name: 'Show assets panel' }))
const showAssetsButton = wrapper.get(
'button[aria-label="Show assets panel"]'
)
await showAssetsButton.trigger('click')
expect(showAssetsSpy).toHaveBeenCalledOnce()
expect(wrapper.emitted('showAssets')).toHaveLength(1)
})
it('hides the assets icon button when hideShowAssetsAction is true', () => {
render(JobFiltersBar, {
const wrapper = mount(JobFiltersBar, {
props: {
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
@@ -95,7 +93,7 @@ describe('JobFiltersBar', () => {
})
expect(
screen.queryByRole('button', { name: 'Show assets panel' })
).not.toBeInTheDocument()
wrapper.find('button[aria-label="Show assets panel"]').exists()
).toBe(false)
})
})

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
@@ -23,12 +23,7 @@ const QueueJobItemStub = defineComponent({
runningNodeName: { type: String, default: undefined },
activeDetailsId: { type: String, default: null }
},
template: `
<div class="queue-job-item-stub" :data-job-id="jobId" :data-active-details-id="activeDetailsId">
<div :data-testid="'enter-' + jobId" @click="$emit('details-enter', jobId)" />
<div :data-testid="'leave-' + jobId" @click="$emit('details-leave', jobId)" />
</div>
`
template: '<div class="queue-job-item-stub"></div>'
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
@@ -51,16 +46,8 @@ const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
}
}
function getActiveDetailsId(container: Element, jobId: string): string | null {
return (
container
.querySelector(`[data-job-id="${jobId}"]`)
?.getAttribute('data-active-details-id') ?? null
)
}
const renderComponent = (groups: JobGroup[]) =>
render(JobGroupsList, {
const mountComponent = (groups: JobGroup[]) =>
mount(JobGroupsList, {
props: { displayedJobGroups: groups },
global: {
stubs: {
@@ -77,60 +64,64 @@ describe('JobGroupsList hover behavior', () => {
it('delays showing and hiding details while hovering over job rows', async () => {
vi.useFakeTimers()
const job = createJobItem({ id: 'job-d' })
const { container } = renderComponent([
const wrapper = mountComponent([
{ key: 'today', label: 'Today', items: [job] }
])
const jobItem = wrapper.findComponent(QueueJobItemStub)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-d'))
jobItem.vm.$emit('details-enter', job.id)
vi.advanceTimersByTime(199)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
vi.advanceTimersByTime(1)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-d'))
wrapper.findComponent(QueueJobItemStub).vm.$emit('details-leave', job.id)
vi.advanceTimersByTime(149)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
vi.advanceTimersByTime(1)
await nextTick()
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
})
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
vi.useFakeTimers()
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
const { container } = renderComponent([
const wrapper = mountComponent([
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
])
const jobItems = wrapper.findAllComponents(QueueJobItemStub)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-1'))
jobItems[0].vm.$emit('details-enter', firstJob.id)
vi.advanceTimersByTime(200)
await nextTick()
expect(getActiveDetailsId(container, 'job-1')).toBe(firstJob.id)
expect(jobItems[0].props('activeDetailsId')).toBe(firstJob.id)
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-1'))
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('enter-job-2'))
jobItems[0].vm.$emit('details-leave', firstJob.id)
jobItems[1].vm.$emit('details-enter', secondJob.id)
vi.advanceTimersByTime(100)
await nextTick()
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(screen.getByTestId('leave-job-2'))
jobItems[1].vm.$emit('details-leave', secondJob.id)
vi.advanceTimersByTime(50)
await nextTick()
expect(getActiveDetailsId(container, 'job-1')).toBeNull()
expect(jobItems[0].props('activeDetailsId')).toBeNull()
vi.advanceTimersByTime(50)
await nextTick()
expect(getActiveDetailsId(container, 'job-2')).toBeNull()
expect(jobItems[1].props('activeDetailsId')).toBeNull()
})
})

View File

@@ -1,6 +1,5 @@
import { mount, flushPromises } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -90,21 +89,16 @@ describe('ErrorNodeCard.vue', () => {
})
})
function renderCard(
card: ErrorCardData,
options: { initialState?: Record<string, unknown> } = {}
) {
const user = userEvent.setup()
const onCopyToClipboard = vi.fn()
render(ErrorNodeCard, {
props: { card, onCopyToClipboard },
function mountCard(card: ErrorCardData) {
return mount(ErrorNodeCard, {
props: { card },
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState: options.initialState ?? {
initialState: {
systemStats: {
systemStats: {
system: {
@@ -138,7 +132,6 @@ describe('ErrorNodeCard.vue', () => {
}
}
})
return { user, onCopyToClipboard }
}
let cardIdCounter = 0
@@ -180,82 +173,76 @@ describe('ErrorNodeCard.vue', () => {
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
mockGenerateErrorReport.mockReturnValue(reportText)
renderCard(makeRuntimeErrorCard())
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
await waitFor(() => {
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
})
expect(screen.getByText(/System Information/)).toBeInTheDocument()
expect(screen.getByText(/OS: Linux/)).toBeInTheDocument()
expect(wrapper.text()).toContain('ComfyUI Error Report')
expect(wrapper.text()).toContain('System Information')
expect(wrapper.text()).toContain('OS: Linux')
})
it('does not generate report for non-runtime errors', async () => {
renderCard(makeValidationErrorCard())
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
mountCard(makeValidationErrorCard())
await flushPromises()
expect(mockGetLogs).not.toHaveBeenCalled()
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
})
it('displays original details for non-runtime errors', async () => {
renderCard(makeValidationErrorCard())
const wrapper = mountCard(makeValidationErrorCard())
await flushPromises()
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
expect(wrapper.text()).toContain('Input: text')
expect(wrapper.text()).not.toContain('ComfyUI Error Report')
})
it('copies enriched report when copy button is clicked for runtime error', async () => {
const reportText = '# Full Report Content'
mockGenerateErrorReport.mockReturnValue(reportText)
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
await waitFor(() => {
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
})
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))!
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
await user.click(screen.getByRole('button', { name: /Copy/ }))
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
expect(onCopyToClipboard.mock.calls[0][0]).toContain(
'# Full Report Content'
)
const emitted = wrapper.emitted('copyToClipboard')
expect(emitted).toHaveLength(1)
expect(emitted![0][0]).toContain('# Full Report Content')
})
it('copies original details when copy button is clicked for validation error', async () => {
const { user, onCopyToClipboard } = renderCard(makeValidationErrorCard())
const wrapper = mountCard(makeValidationErrorCard())
await flushPromises()
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))!
await copyButton.trigger('click')
await user.click(screen.getByRole('button', { name: /Copy/ }))
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
expect(onCopyToClipboard.mock.calls[0][0]).toBe(
'Required input is missing\n\nInput: text'
)
const emitted = wrapper.emitted('copyToClipboard')
expect(emitted).toHaveLength(1)
expect(emitted![0][0]).toBe('Required input is missing\n\nInput: text')
})
it('generates report with fallback logs when getLogs fails', async () => {
mockGetLogs.mockRejectedValue(new Error('Network error'))
renderCard(makeRuntimeErrorCard())
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
// Report is still generated with fallback log message
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
serverLogs: 'Failed to retrieve server logs'
})
)
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
expect(wrapper.text()).toContain('ComfyUI Error Report')
})
it('falls back to original details when generateErrorReport throws', async () => {
@@ -263,25 +250,24 @@ describe('ErrorNodeCard.vue', () => {
throw new Error('Serialization error')
})
renderCard(makeRuntimeErrorCard())
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
await waitFor(() => {
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
expect(wrapper.text()).toContain('Traceback line 1')
})
it('opens GitHub issues search when Find Issue button is clicked', async () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const { user } = renderCard(makeRuntimeErrorCard())
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Find on GitHub/ })
).toBeInTheDocument()
})
const findIssuesButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Find on GitHub'))!
expect(findIssuesButton.exists()).toBe(true)
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
await findIssuesButton.trigger('click')
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('github.com/comfyanonymous/ComfyUI/issues?q='),
@@ -298,15 +284,15 @@ describe('ErrorNodeCard.vue', () => {
})
it('executes ContactSupport command when Get Help button is clicked', async () => {
const { user } = renderCard(makeRuntimeErrorCard())
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Get Help/ })
).toBeInTheDocument()
})
const getHelpButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Get Help'))!
expect(getHelpButton.exists()).toBe(true)
await user.click(screen.getByRole('button', { name: /Get Help/ }))
await getHelpButton.trigger('click')
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
@@ -318,11 +304,9 @@ describe('ErrorNodeCard.vue', () => {
})
it('passes exceptionType from error item to report generator', async () => {
renderCard(makeRuntimeErrorCard())
mountCard(makeRuntimeErrorCard())
await flushPromises()
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
exceptionType: 'RuntimeError'
@@ -345,11 +329,9 @@ describe('ErrorNodeCard.vue', () => {
]
}
renderCard(card)
mountCard(card)
await flushPromises()
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
exceptionType: 'Runtime Error'
@@ -358,16 +340,30 @@ describe('ErrorNodeCard.vue', () => {
})
it('falls back to original details when systemStats is unavailable', async () => {
renderCard(makeRuntimeErrorCard(), {
initialState: {
systemStats: { systemStats: null }
const wrapper = mount(ErrorNodeCard, {
props: { card: makeRuntimeErrorCard() },
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState: {
systemStats: { systemStats: null }
}
})
],
stubs: {
Button: {
template:
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
}
}
}
})
await waitFor(() => {
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
await flushPromises()
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Traceback line 1')
})
})

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -69,13 +68,7 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
vi.mock('./MissingPackGroupRow.vue', () => ({
default: {
name: 'MissingPackGroupRow',
template: `<div class="pack-row" data-testid="pack-row"
:data-show-info-button="String(showInfoButton)"
:data-show-node-id-badge="String(showNodeIdBadge)"
>
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
</div>`,
template: '<div class="pack-row" />',
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
emits: ['locate-node', 'open-manager-info']
}
@@ -102,8 +95,7 @@ const i18n = createI18n({
'Some nodes require a newer version of ComfyUI (current: {version}).',
outdatedVersionGeneric:
'Some nodes require a newer version of ComfyUI.',
coreNodesFromVersion: 'Requires ComfyUI {version}:',
unknownVersion: 'unknown'
coreNodesFromVersion: 'Requires ComfyUI {version}:'
}
}
},
@@ -121,15 +113,14 @@ function makePackGroups(count = 2): MissingPackGroup[] {
}))
}
function renderCard(
function mountCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
const user = userEvent.setup()
const result = render(MissingNodeCard, {
return mount(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
@@ -143,7 +134,6 @@ function renderCard(
}
}
})
return { ...result, user }
}
describe('MissingNodeCard', () => {
@@ -161,163 +151,131 @@ describe('MissingNodeCard', () => {
describe('Rendering & Props', () => {
it('renders cloud message when isCloud is true', () => {
mockIsCloud.value = true
renderCard()
expect(
screen.getByText('Unsupported node packs detected.')
).toBeInTheDocument()
const wrapper = mountCard()
expect(wrapper.text()).toContain('Unsupported node packs detected')
})
it('renders OSS message when isCloud is false', () => {
renderCard()
expect(
screen.getByText('Missing node packs detected. Install them.')
).toBeInTheDocument()
const wrapper = mountCard()
expect(wrapper.text()).toContain('Missing node packs detected')
})
it('renders correct number of MissingPackGroupRow components', () => {
renderCard({ missingPackGroups: makePackGroups(3) })
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)
const wrapper = mountCard({ missingPackGroups: makePackGroups(3) })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(3)
})
it('renders zero rows when missingPackGroups is empty', () => {
renderCard({ missingPackGroups: [] })
expect(screen.queryAllByTestId('pack-row')).toHaveLength(0)
const wrapper = mountCard({ missingPackGroups: [] })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(0)
})
it('passes props correctly to MissingPackGroupRow children', () => {
renderCard({
const wrapper = mountCard({
showInfoButton: true,
showNodeIdBadge: true
})
const row = screen.getAllByTestId('pack-row')[0]
expect(row.getAttribute('data-show-info-button')).toBe('true')
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
expect(row.props('showInfoButton')).toBe(true)
expect(row.props('showNodeIdBadge')).toBe(true)
})
})
describe('Manager Disabled Hint', () => {
it('shows hint when OSS and manager is disabled (showInfoButton false)', () => {
mockIsCloud.value = false
renderCard({ showInfoButton: false })
expect(
screen.getByText('pip install -U --pre comfyui-manager')
).toBeInTheDocument()
expect(screen.getByText('--enable-manager')).toBeInTheDocument()
const wrapper = mountCard({ showInfoButton: false })
expect(wrapper.text()).toContain('pip install -U --pre comfyui-manager')
expect(wrapper.text()).toContain('--enable-manager')
})
it('hides hint when manager is enabled (showInfoButton true)', () => {
mockIsCloud.value = false
renderCard({ showInfoButton: true })
expect(screen.queryByText('--enable-manager')).not.toBeInTheDocument()
const wrapper = mountCard({ showInfoButton: true })
expect(wrapper.text()).not.toContain('--enable-manager')
})
it('hides hint on Cloud even when showInfoButton is false', () => {
mockIsCloud.value = true
renderCard({ showInfoButton: false })
expect(screen.queryByText('--enable-manager')).not.toBeInTheDocument()
const wrapper = mountCard({ showInfoButton: false })
expect(wrapper.text()).not.toContain('--enable-manager')
})
})
describe('Apply Changes Section', () => {
it('hides Apply Changes when manager is not enabled', () => {
mockShouldShowManagerButtons.value = false
renderCard()
expect(screen.queryByText('Apply Changes')).not.toBeInTheDocument()
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('hides Apply Changes when manager enabled but no packs pending', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
renderCard()
expect(screen.queryByText('Apply Changes')).not.toBeInTheDocument()
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('shows Apply Changes when at least one pack is pending restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
renderCard()
expect(screen.getByText('Apply Changes')).toBeInTheDocument()
const wrapper = mountCard()
expect(wrapper.text()).toContain('Apply Changes')
})
it('displays spinner during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
renderCard()
expect(screen.getByRole('status')).toBeInTheDocument()
const wrapper = mountCard()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('disables button during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
renderCard()
expect(
screen.getByRole('button', { name: /apply changes/i })
).toBeDisabled()
const wrapper = mountCard()
const btn = wrapper.find('button')
expect(btn.attributes('disabled')).toBeDefined()
})
it('calls applyChanges when Apply Changes button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const { user } = renderCard()
await user.click(screen.getByRole('button', { name: /apply changes/i }))
const wrapper = mountCard()
const btn = wrapper.find('button')
await btn.trigger('click')
expect(mockApplyChanges).toHaveBeenCalledOnce()
})
})
describe('Event Handling', () => {
it('emits locateNode when child emits locate-node', async () => {
const onLocateNode = vi.fn()
const user = userEvent.setup()
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onLocateNode
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
}
}
})
await user.click(screen.getAllByTestId('locate-node')[0])
expect(onLocateNode).toHaveBeenCalledWith('0')
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('locate-node', '42')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['42'])
})
it('emits openManagerInfo when child emits open-manager-info', async () => {
const onOpenManagerInfo = vi.fn()
const user = userEvent.setup()
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onOpenManagerInfo
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
}
}
})
await user.click(screen.getAllByTestId('open-manager-info')[0])
expect(onOpenManagerInfo).toHaveBeenCalledWith('pack-0')
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('open-manager-info', 'pack-0')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['pack-0'])
})
})
describe('Core Node Version Warning', () => {
it('does not render warning when no missing core nodes', () => {
const { container } = renderCard()
expect(container.textContent).not.toContain('newer version of ComfyUI')
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('newer version of ComfyUI')
})
it('renders warning with version when missing core nodes exist', () => {
@@ -325,20 +283,20 @@ describe('MissingNodeCard', () => {
'1.2.0': [{ type: 'TestNode' }]
}
mockSystemStats.value = { system: { comfyui_version: '1.0.0' } }
const { container } = renderCard()
expect(container.textContent).toContain('(current: 1.0.0)')
expect(container.textContent).toContain('Requires ComfyUI 1.2.0:')
expect(container.textContent).toContain('TestNode')
const wrapper = mountCard()
expect(wrapper.text()).toContain('(current: 1.0.0)')
expect(wrapper.text()).toContain('Requires ComfyUI 1.2.0:')
expect(wrapper.text()).toContain('TestNode')
})
it('renders generic message when version is unavailable', () => {
mockMissingCoreNodes.value = {
'1.2.0': [{ type: 'TestNode' }]
}
renderCard()
expect(
screen.getByText('Some nodes require a newer version of ComfyUI.')
).toBeInTheDocument()
const wrapper = mountCard()
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI.'
)
})
it('does not render warning on Cloud', () => {
@@ -346,8 +304,8 @@ describe('MissingNodeCard', () => {
mockMissingCoreNodes.value = {
'1.2.0': [{ type: 'TestNode' }]
}
const { container } = renderCard()
expect(container.textContent).not.toContain('newer version of ComfyUI')
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('newer version of ComfyUI')
})
it('deduplicates and sorts node names within a version', () => {
@@ -358,10 +316,9 @@ describe('MissingNodeCard', () => {
{ type: 'ZebraNode' }
]
}
const { container } = renderCard()
expect(container.textContent).toContain('AlphaNode, ZebraNode')
// eslint-disable-next-line testing-library/no-container
expect(container.textContent?.match(/ZebraNode/g)).toHaveLength(1)
const wrapper = mountCard()
expect(wrapper.text()).toContain('AlphaNode, ZebraNode')
expect(wrapper.text().match(/ZebraNode/g)?.length).toBe(1)
})
it('sorts versions in descending order', () => {
@@ -370,8 +327,8 @@ describe('MissingNodeCard', () => {
'1.3.0': [{ type: 'Node3' }],
'1.2.0': [{ type: 'Node2' }]
}
const { container } = renderCard()
const text = container.textContent ?? ''
const wrapper = mountCard()
const text = wrapper.text()
const v13 = text.indexOf('1.3.0')
const v12 = text.indexOf('1.2.0')
const v11 = text.indexOf('1.1.0')
@@ -384,11 +341,11 @@ describe('MissingNodeCard', () => {
'': [{ type: 'NoVersionNode' }],
'1.2.0': [{ type: 'VersionedNode' }]
}
const { container } = renderCard()
expect(container.textContent).toContain('Requires ComfyUI 1.2.0:')
expect(container.textContent).toContain('VersionedNode')
expect(container.textContent).toContain('unknown')
expect(container.textContent).toContain('NoVersionNode')
const wrapper = mountCard()
expect(wrapper.text()).toContain('Requires ComfyUI 1.2.0:')
expect(wrapper.text()).toContain('VersionedNode')
expect(wrapper.text()).toContain('unknown')
expect(wrapper.text()).toContain('NoVersionNode')
})
})
})

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -96,23 +95,18 @@ function makeGroup(
}
}
function renderRow(
function mountRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
const user = userEvent.setup()
const onLocateNode = vi.fn()
const onOpenManagerInfo = vi.fn()
render(MissingPackGroupRow, {
return mount(MissingPackGroupRow, {
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
onLocateNode,
onOpenManagerInfo,
...props
},
global: {
@@ -125,7 +119,6 @@ function renderRow(
}
}
})
return { user, onLocateNode, onOpenManagerInfo }
}
describe('MissingPackGroupRow', () => {
@@ -142,27 +135,27 @@ describe('MissingPackGroupRow', () => {
describe('Basic Rendering', () => {
it('renders pack name from packId', () => {
renderRow()
expect(screen.getByText(/my-pack/)).toBeInTheDocument()
const wrapper = mountRow()
expect(wrapper.text()).toContain('my-pack')
})
it('renders "Unknown pack" when packId is null', () => {
renderRow({ group: makeGroup({ packId: null }) })
expect(screen.getByText(/Unknown pack/)).toBeInTheDocument()
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).toContain('Unknown pack')
})
it('renders loading text when isResolving is true', () => {
renderRow({ group: makeGroup({ isResolving: true }) })
expect(screen.getByText(/Loading/)).toBeInTheDocument()
const wrapper = mountRow({ group: makeGroup({ isResolving: true }) })
expect(wrapper.text()).toContain('Loading')
})
it('renders node count', () => {
renderRow()
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
const wrapper = mountRow()
expect(wrapper.text()).toContain('(2)')
})
it('renders count of 5 for 5 nodeTypes', () => {
renderRow({
const wrapper = mountRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: `Node${i}`,
@@ -171,39 +164,39 @@ describe('MissingPackGroupRow', () => {
}))
})
})
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
expect(wrapper.text()).toContain('(5)')
})
})
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('MissingA')
})
it('expands when chevron is clicked', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
expect(wrapper.text()).toContain('MissingB')
})
it('collapses when chevron is clicked again', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
await wrapper.get('button[aria-label="Collapse"]').trigger('click')
expect(wrapper.text()).not.toContain('MissingA')
})
})
describe('Node Type List', () => {
async function expand(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Expand' }))
async function expand(wrapper: ReturnType<typeof mountRow>) {
await wrapper.get('button[aria-label="Expand"]').trigger('click')
}
it('renders all nodeTypes when expanded', async () => {
const { user } = renderRow({
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
@@ -212,47 +205,48 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(user)
expect(screen.getByText('NodeA')).toBeInTheDocument()
expect(screen.getByText('NodeB')).toBeInTheDocument()
expect(screen.getByText('NodeC')).toBeInTheDocument()
await expand(wrapper)
expect(wrapper.text()).toContain('NodeA')
expect(wrapper.text()).toContain('NodeB')
expect(wrapper.text()).toContain('NodeC')
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { user } = renderRow({ showNodeIdBadge: true })
await expand(user)
expect(screen.getByText('#10')).toBeInTheDocument()
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
expect(wrapper.text()).toContain('#10')
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { user } = renderRow({ showNodeIdBadge: false })
await expand(user)
expect(screen.queryByText('#10')).not.toBeInTheDocument()
const wrapper = mountRow({ showNodeIdBadge: false })
await expand(wrapper)
expect(wrapper.text()).not.toContain('#10')
})
it('emits locateNode when Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
await expand(user)
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
await wrapper
.get('button[aria-label="Locate node on canvas"]')
.trigger('click')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['10'])
})
it('does not show Locate for nodeType without nodeId', async () => {
const { user } = renderRow({
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(user)
await expand(wrapper)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
wrapper.find('button[aria-label="Locate node on canvas"]').exists()
).toBe(false)
})
it('handles mixed nodeTypes with and without nodeId', async () => {
const { user } = renderRow({
const wrapper = mountRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
@@ -261,11 +255,11 @@ describe('MissingPackGroupRow', () => {
]
})
})
await expand(user)
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
await expand(wrapper)
expect(wrapper.text()).toContain('WithId')
expect(wrapper.text()).toContain('WithoutId')
expect(
screen.getAllByRole('button', { name: 'Locate node on canvas' })
wrapper.findAll('button[aria-label="Locate node on canvas"]')
).toHaveLength(1)
})
})
@@ -273,103 +267,102 @@ describe('MissingPackGroupRow', () => {
describe('Manager Integration', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
renderRow()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('Install node pack')
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
renderRow({ group: makeGroup({ packId: null }) })
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).not.toContain('Install node pack')
})
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
renderRow()
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
const wrapper = mountRow()
expect(wrapper.text()).toContain('Search in Node Manager')
})
it('shows "Installed" state when pack is installed', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
renderRow()
expect(screen.getByText('Installed')).toBeInTheDocument()
const wrapper = mountRow()
expect(wrapper.text()).toContain('Installed')
})
it('shows spinner when installing', () => {
mockShouldShowManagerButtons.value = true
mockIsInstalling.value = true
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
renderRow()
expect(screen.getByRole('status')).toBeInTheDocument()
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('shows install button when not installed and pack found', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
renderRow()
expect(screen.getByText('Install node pack')).toBeInTheDocument()
const wrapper = mountRow()
expect(wrapper.text()).toContain('Install node pack')
})
it('calls installAllPacks when Install button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const { user } = renderRow()
await user.click(
screen.getByRole('button', { name: /Install node pack/ })
)
const wrapper = mountRow()
await wrapper.get('button:not([aria-label])').trigger('click')
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
it('shows loading spinner when registry is loading', () => {
mockShouldShowManagerButtons.value = true
mockIsLoading.value = true
renderRow()
expect(screen.getByRole('status')).toBeInTheDocument()
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
})
describe('Info Button', () => {
it('shows Info button when showInfoButton true and packId not null', () => {
renderRow({ showInfoButton: true })
const wrapper = mountRow({ showInfoButton: true })
expect(
screen.getByRole('button', { name: 'View in Manager' })
).toBeInTheDocument()
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(true)
})
it('hides Info button when showInfoButton is false', () => {
renderRow({ showInfoButton: false })
const wrapper = mountRow({ showInfoButton: false })
expect(
screen.queryByRole('button', { name: 'View in Manager' })
).not.toBeInTheDocument()
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
})
it('hides Info button when packId is null', () => {
renderRow({
const wrapper = mountRow({
showInfoButton: true,
group: makeGroup({ packId: null })
})
expect(
screen.queryByRole('button', { name: 'View in Manager' })
).not.toBeInTheDocument()
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
})
it('emits openManagerInfo when Info button is clicked', async () => {
const { user, onOpenManagerInfo } = renderRow({ showInfoButton: true })
await user.click(screen.getByRole('button', { name: 'View in Manager' }))
expect(onOpenManagerInfo).toHaveBeenCalledWith('my-pack')
const wrapper = mountRow({ showInfoButton: true })
await wrapper.get('button[aria-label="View in Manager"]').trigger('click')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['my-pack'])
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
renderRow({ group: makeGroup({ nodeTypes: [] }) })
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
const wrapper = mountRow({ group: makeGroup({ nodeTypes: [] }) })
expect(wrapper.text()).toContain('(0)')
})
})
})

View File

@@ -1,11 +1,11 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
// Mock dependencies
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
@@ -61,9 +61,8 @@ describe('TabErrors.vue', () => {
})
})
function renderComponent(initialState = {}) {
const user = userEvent.setup()
render(TabErrors, {
function mountComponent(initialState = {}) {
return mount(TabErrors, {
global: {
plugins: [
PrimeVue,
@@ -87,16 +86,15 @@ describe('TabErrors.vue', () => {
}
}
})
return { user }
}
it('renders "no errors" state when store is empty', () => {
renderComponent()
expect(screen.getByText('No errors')).toBeInTheDocument()
const wrapper = mountComponent()
expect(wrapper.text()).toContain('No errors')
})
it('renders prompt-level errors (Group title = error message)', async () => {
renderComponent({
const wrapper = mountComponent({
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
@@ -106,9 +104,12 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
expect(screen.getByText('Prompt has no outputs')).toBeInTheDocument()
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
// Group title should be the raw message from store
expect(wrapper.text()).toContain('Server Error: No outputs')
// Item message should be localized desc
expect(wrapper.text()).toContain('Prompt has no outputs')
// Details should not be rendered for prompt errors
expect(wrapper.text()).not.toContain('Error details')
})
it('renders node validation errors grouped by class_type', async () => {
@@ -117,7 +118,7 @@ describe('TabErrors.vue', () => {
title: 'CLIP Text Encode'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
const wrapper = mountComponent({
executionError: {
lastNodeErrors: {
'6': {
@@ -130,10 +131,10 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
expect(screen.getByText('#6')).toBeInTheDocument()
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('#6')
expect(wrapper.text()).toContain('CLIP Text Encode')
expect(wrapper.text()).toContain('Required input is missing')
})
it('renders runtime execution errors from WebSocket', async () => {
@@ -142,7 +143,7 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
const wrapper = mountComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
@@ -156,17 +157,17 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('#10')).toBeInTheDocument()
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
expect(wrapper.text()).toContain('KSampler')
expect(wrapper.text()).toContain('#10')
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
expect(wrapper.text()).toContain('Line 1')
})
it('filters errors based on search query', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const { user } = renderComponent({
const wrapper = mountComponent({
executionError: {
lastNodeErrors: {
'1': {
@@ -181,17 +182,14 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
1
)
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('KSampler')
await user.type(screen.getByRole('textbox'), 'Missing text input')
const searchInput = wrapper.find('input')
await searchInput.setValue('Missing text input')
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
1
)
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).not.toContain('KSampler')
})
it('calls copyToClipboard when copy button is clicked', async () => {
@@ -200,7 +198,7 @@ describe('TabErrors.vue', () => {
const mockCopy = vi.fn()
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const { user } = renderComponent({
const wrapper = mountComponent({
executionError: {
lastNodeErrors: {
'1': {
@@ -211,7 +209,9 @@ describe('TabErrors.vue', () => {
}
})
await user.click(screen.getByTestId('error-card-copy'))
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
@@ -222,7 +222,7 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
const wrapper = mountComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
@@ -236,9 +236,15 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
// Runtime error panel title should show class type
expect(wrapper.text()).toContain('KSampler')
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
// Should render in the dedicated runtime error panel, not inside accordion
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
expect(runtimePanel.exists()).toBe(true)
// Verify the error message appears exactly once (not duplicated in accordion)
expect(
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
).toHaveLength(1)
})
})

View File

@@ -1,7 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import type { Slots } from 'vue'
import { h } from 'vue'
@@ -104,56 +103,58 @@ describe('WidgetActions', () => {
})
}
function renderWidgetActions(
widget: IBaseWidget,
node: LGraphNode,
extraProps: Record<string, unknown> = {}
) {
const user = userEvent.setup()
const onResetToDefault = vi.fn()
render(WidgetActions, {
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
return mount(WidgetActions, {
props: {
widget,
node,
label: 'Test Widget',
onResetToDefault,
...extraProps
label: 'Test Widget'
},
global: {
plugins: [i18n]
}
})
return { user, onResetToDefault }
}
it('shows reset button when widget has default value', () => {
const widget = createMockWidget()
const node = createMockNode()
renderWidgetActions(widget, node)
const wrapper = mountWidgetActions(widget, node)
expect(screen.getByRole('button', { name: /Reset/ })).toBeInTheDocument()
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(resetButton).toBeDefined()
})
it('emits resetToDefault with default value when reset button clicked', async () => {
const widget = createMockWidget(100)
const node = createMockNode()
const { user, onResetToDefault } = renderWidgetActions(widget, node)
const wrapper = mountWidgetActions(widget, node)
await user.click(screen.getByRole('button', { name: /Reset/ }))
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(onResetToDefault).toHaveBeenCalledTimes(1)
expect(onResetToDefault).toHaveBeenCalledWith(42)
await resetButton?.trigger('click')
expect(wrapper.emitted('resetToDefault')).toHaveLength(1)
expect(wrapper.emitted('resetToDefault')![0]).toEqual([42])
})
it('disables reset button when value equals default', () => {
const widget = createMockWidget(42)
const node = createMockNode()
renderWidgetActions(widget, node)
const wrapper = mountWidgetActions(widget, node)
expect(screen.getByRole('button', { name: /Reset/ })).toBeDisabled()
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(resetButton?.attributes('disabled')).toBeDefined()
})
it('does not show reset button when no default value exists', () => {
@@ -164,11 +165,13 @@ describe('WidgetActions', () => {
const widget = createMockWidget(100)
const node = createMockNode()
renderWidgetActions(widget, node)
const wrapper = mountWidgetActions(widget, node)
expect(
screen.queryByRole('button', { name: /Reset/ })
).not.toBeInTheDocument()
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(resetButton).toBeUndefined()
})
it('uses fallback default for INT type without explicit default', async () => {
@@ -179,11 +182,15 @@ describe('WidgetActions', () => {
const widget = createMockWidget(100)
const node = createMockNode()
const { user, onResetToDefault } = renderWidgetActions(widget, node)
const wrapper = mountWidgetActions(widget, node)
await user.click(screen.getByRole('button', { name: /Reset/ }))
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(onResetToDefault).toHaveBeenCalledWith(0)
await resetButton?.trigger('click')
expect(wrapper.emitted('resetToDefault')![0]).toEqual([0])
})
it('uses first option as default for combo without explicit default', async () => {
@@ -195,11 +202,15 @@ describe('WidgetActions', () => {
const widget = createMockWidget(100)
const node = createMockNode()
const { user, onResetToDefault } = renderWidgetActions(widget, node)
const wrapper = mountWidgetActions(widget, node)
await user.click(screen.getByRole('button', { name: /Reset/ }))
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(onResetToDefault).toHaveBeenCalledWith('option1')
await resetButton?.trigger('click')
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
})
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
@@ -237,8 +248,7 @@ describe('WidgetActions', () => {
disambiguatingSourceNodeId: '1'
})
const user = userEvent.setup()
render(WidgetActions, {
const wrapper = mount(WidgetActions, {
props: {
widget,
node,
@@ -251,7 +261,11 @@ describe('WidgetActions', () => {
}
})
await user.click(screen.getByRole('button', { name: /Hide input/ }))
const hideButton = wrapper
.findAll('button')
.find((button) => button.text().includes('Hide input'))
expect(hideButton).toBeDefined()
await hideButton?.trigger('click')
expect(
promotionStore.isPromoted('graph-test', 4, {

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -14,8 +14,7 @@ const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
StubWidgetComponent: {
name: 'StubWidget',
props: ['widget', 'modelValue', 'nodeId', 'nodeType'],
template:
'<div class="stub-widget" :data-widget-options="JSON.stringify(widget?.options)" :data-widget-type="widget?.type" :data-widget-name="widget?.name" :data-widget-value="String(widget?.value)" />'
template: '<div class="stub-widget" />'
}
}))
@@ -133,11 +132,11 @@ function createMockPromotedWidgetView(
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
}
function renderWidgetItem(
function mountWidgetItem(
widget: IBaseWidget,
node: LGraphNode = createMockNode()
) {
return render(WidgetItem, {
return mount(WidgetItem, {
props: { widget, node },
global: {
plugins: [i18n],
@@ -149,18 +148,6 @@ function renderWidgetItem(
})
}
function getStubWidget(container: Element) {
// eslint-disable-next-line testing-library/no-node-access
const el = container.querySelector('.stub-widget')
if (!el) throw new Error('stub-widget not found')
return {
options: JSON.parse(el.getAttribute('data-widget-options') ?? 'null'),
type: el.getAttribute('data-widget-type'),
name: el.getAttribute('data-widget-name'),
value: el.getAttribute('data-widget-value')
}
}
describe('WidgetItem', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -172,10 +159,10 @@ describe('WidgetItem', () => {
const widget = createMockWidget({
options: { values: ['a', 'b', 'c'] }
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
expect(stub.options).toEqual({
expect(stub.props('widget').options).toEqual({
values: ['a', 'b', 'c']
})
})
@@ -185,34 +172,34 @@ describe('WidgetItem', () => {
values: ['model_a.safetensors', 'model_b.safetensors']
}
const widget = createMockPromotedWidgetView(expectedOptions)
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
expect(stub.options).toEqual(expectedOptions)
expect(stub.props('widget').options).toEqual(expectedOptions)
})
it('passes type from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
expect(stub.type).toBe('combo')
expect(stub.props('widget').type).toBe('combo')
})
it('passes name from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
expect(stub.name).toBe('ckpt_name')
expect(stub.props('widget').name).toBe('ckpt_name')
})
it('passes value from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
const wrapper = mountWidgetItem(widget)
const stub = wrapper.findComponent(StubWidgetComponent)
expect(stub.value).toBe('model_a.safetensors')
expect(stub.props('widget').value).toBe('model_a.safetensors')
})
})
})

View File

@@ -1,5 +1,4 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -24,48 +23,25 @@ describe('NodeSearchCategorySidebar', () => {
setupTestPinia()
})
async function createRender(props = {}) {
const user = userEvent.setup()
const onUpdateSelectedCategory = vi.fn()
const baseProps = { selectedCategory: 'most-relevant', ...props }
let currentProps = { ...baseProps }
let rerenderFn: (
p: typeof baseProps & Record<string, unknown>
) => void = () => {}
function makeProps(overrides = {}) {
const merged = { ...currentProps, ...overrides }
return {
...merged,
'onUpdate:selectedCategory': (val: string) => {
onUpdateSelectedCategory(val)
currentProps = { ...currentProps, selectedCategory: val }
rerenderFn(makeProps())
}
}
}
const result = render(NodeSearchCategorySidebar, {
props: makeProps(),
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: 'most-relevant', ...props },
global: { plugins: [testI18n] }
})
rerenderFn = (p) => result.rerender(p)
await nextTick()
return { user, onUpdateSelectedCategory }
return wrapper
}
async function clickCategory(
user: ReturnType<typeof userEvent.setup>,
wrapper: ReturnType<typeof mount>,
text: string,
exact = false
) {
const buttons = screen.getAllByRole('button')
const btn = buttons.find((b) =>
exact ? b.textContent?.trim() === text : b.textContent?.includes(text)
)
const btn = wrapper
.findAll('button')
.find((b) => (exact ? b.text().trim() === text : b.text().includes(text)))
expect(btn, `Expected to find a button with text "${text}"`).toBeDefined()
await user.click(btn!)
await btn!.trigger('click')
await nextTick()
}
@@ -80,35 +56,37 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
await createRender()
const wrapper = await createWrapper()
expect(screen.getByText('Most relevant')).toBeInTheDocument()
expect(screen.getByText('Recents')).toBeInTheDocument()
expect(screen.getByText('Favorites')).toBeInTheDocument()
expect(screen.getByText('Essentials')).toBeInTheDocument()
expect(screen.getByText('Blueprints')).toBeInTheDocument()
expect(screen.getByText('Partner')).toBeInTheDocument()
expect(screen.getByText('Comfy')).toBeInTheDocument()
expect(screen.getByText('Extensions')).toBeInTheDocument()
expect(wrapper.text()).toContain('Most relevant')
expect(wrapper.text()).toContain('Recents')
expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).toContain('Essentials')
expect(wrapper.text()).toContain('Blueprints')
expect(wrapper.text()).toContain('Partner')
expect(wrapper.text()).toContain('Comfy')
expect(wrapper.text()).toContain('Extensions')
})
it('should mark the selected preset category as selected', async () => {
await createRender({ selectedCategory: 'most-relevant' })
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
expect(screen.getByTestId('category-most-relevant')).toHaveAttribute(
'aria-current',
'true'
const mostRelevantBtn = wrapper.find(
'[data-testid="category-most-relevant"]'
)
expect(mostRelevantBtn.attributes('aria-current')).toBe('true')
})
it('should emit update:selectedCategory when preset is clicked', async () => {
const { user, onUpdateSelectedCategory } = await createRender({
selectedCategory: 'most-relevant'
})
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
await clickCategory(user, 'Favorites')
await clickCategory(wrapper, 'Favorites')
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('favorites')
expect(wrapper.emitted('update:selectedCategory')).toBeTruthy()
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'favorites'
])
})
})
@@ -121,11 +99,11 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
await createRender()
const wrapper = await createWrapper()
expect(screen.getByText('sampling')).toBeInTheDocument()
expect(screen.getByText('loaders')).toBeInTheDocument()
expect(screen.getByText('conditioning')).toBeInTheDocument()
expect(wrapper.text()).toContain('sampling')
expect(wrapper.text()).toContain('loaders')
expect(wrapper.text()).toContain('conditioning')
})
it('should emit update:selectedCategory when category is clicked', async () => {
@@ -134,11 +112,13 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const { user, onUpdateSelectedCategory } = await createRender()
const wrapper = await createWrapper()
await clickCategory(user, 'sampling')
await clickCategory(wrapper, 'sampling')
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'sampling'
])
})
})
@@ -151,16 +131,14 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const { user } = await createRender()
const wrapper = await createWrapper()
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
expect(wrapper.text()).not.toContain('advanced')
await clickCategory(user, 'sampling')
await clickCategory(wrapper, 'sampling')
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
expect(screen.getByText('basic')).toBeInTheDocument()
})
expect(wrapper.text()).toContain('advanced')
expect(wrapper.text()).toContain('basic')
})
it('should collapse sibling category when another is expanded', async () => {
@@ -172,21 +150,17 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const { user } = await createRender()
const wrapper = await createWrapper()
// Expand sampling
await clickCategory(user, 'sampling', true)
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
})
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
// Expand image — sampling should collapse
await clickCategory(user, 'image', true)
await clickCategory(wrapper, 'image', true)
await waitFor(() => {
expect(screen.getByText('upscale')).toBeInTheDocument()
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
})
expect(wrapper.text()).toContain('upscale')
expect(wrapper.text()).not.toContain('advanced')
})
it('should emit update:selectedCategory when subcategory is clicked', async () => {
@@ -196,19 +170,16 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const { user, onUpdateSelectedCategory } = await createRender()
const wrapper = await createWrapper()
// Expand sampling category
await clickCategory(user, 'sampling', true)
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
})
await clickCategory(wrapper, 'sampling', true)
// Click on advanced subcategory
await clickCategory(user, 'advanced')
await clickCategory(wrapper, 'advanced')
const calls = onUpdateSelectedCategory.mock.calls
expect(calls[calls.length - 1]).toEqual(['sampling/advanced'])
const emitted = wrapper.emitted('update:selectedCategory')!
expect(emitted[emitted.length - 1]).toEqual(['sampling/advanced'])
})
})
@@ -219,12 +190,13 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
await createRender({ selectedCategory: 'sampling' })
const wrapper = await createWrapper({ selectedCategory: 'sampling' })
expect(screen.getByTestId('category-sampling')).toHaveAttribute(
'aria-current',
'true'
)
expect(
wrapper
.find('[data-testid="category-sampling"]')
.attributes('aria-current')
).toBe('true')
})
it('should emit selected subcategory when expanded', async () => {
@@ -234,19 +206,14 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const { user, onUpdateSelectedCategory } = await createRender({
selectedCategory: 'most-relevant'
})
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
// Expand and click subcategory
await clickCategory(user, 'sampling', true)
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
})
await clickCategory(user, 'advanced')
await clickCategory(wrapper, 'sampling', true)
await clickCategory(wrapper, 'advanced')
const calls = onUpdateSelectedCategory.mock.calls
expect(calls[calls.length - 1]).toEqual(['sampling/advanced'])
const emitted = wrapper.emitted('update:selectedCategory')!
expect(emitted[emitted.length - 1]).toEqual(['sampling/advanced'])
})
})
@@ -258,31 +225,29 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const { user, onUpdateSelectedCategory } = await createRender()
const wrapper = await createWrapper()
// Only top-level visible initially
expect(screen.getByText('api')).toBeInTheDocument()
expect(screen.queryByText('image')).not.toBeInTheDocument()
expect(screen.queryByText('BFL')).not.toBeInTheDocument()
expect(wrapper.text()).toContain('api')
expect(wrapper.text()).not.toContain('image')
expect(wrapper.text()).not.toContain('BFL')
// Expand api
await clickCategory(user, 'api', true)
await waitFor(() => {
expect(screen.getByText('image')).toBeInTheDocument()
})
expect(screen.queryByText('BFL')).not.toBeInTheDocument()
await clickCategory(wrapper, 'api', true)
expect(wrapper.text()).toContain('image')
expect(wrapper.text()).not.toContain('BFL')
// Expand image
await clickCategory(user, 'image', true)
await waitFor(() => {
expect(screen.getByText('BFL')).toBeInTheDocument()
})
await clickCategory(wrapper, 'image', true)
expect(wrapper.text()).toContain('BFL')
// Click BFL and verify emission
await clickCategory(user, 'BFL', true)
await clickCategory(wrapper, 'BFL', true)
const calls = onUpdateSelectedCategory.mock.calls
expect(calls[calls.length - 1]).toEqual(['api/image/BFL'])
const emitted = wrapper.emitted('update:selectedCategory')!
expect(emitted[emitted.length - 1]).toEqual(['api/image/BFL'])
})
it('should emit category without root/ prefix', async () => {
@@ -291,10 +256,10 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const { user, onUpdateSelectedCategory } = await createRender()
const wrapper = await createWrapper()
await clickCategory(user, 'sampling')
await clickCategory(wrapper, 'sampling')
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
expect(wrapper.emitted('update:selectedCategory')![0][0]).toBe('sampling')
})
})

View File

@@ -1,8 +1,9 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import {
createMockNodeDef,
@@ -31,27 +32,14 @@ describe('NodeSearchContent', () => {
vi.restoreAllMocks()
})
async function renderComponent(props = {}) {
const user = userEvent.setup()
const onAddNode = vi.fn()
const onHoverNode = vi.fn()
const onRemoveFilter = vi.fn()
const onAddFilter = vi.fn()
render(NodeSearchContent, {
props: {
filters: [],
onAddNode,
onHoverNode,
onRemoveFilter,
onAddFilter,
...props
},
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchContent, {
props: { filters: [], ...props },
global: {
plugins: [testI18n],
stubs: {
NodeSearchListItem: {
template:
'<div class="node-item" data-testid="node-item">{{ nodeDef.display_name }}</div>',
template: '<div class="node-item">{{ nodeDef.display_name }}</div>',
props: [
'nodeDef',
'currentQuery',
@@ -64,7 +52,7 @@ describe('NodeSearchContent', () => {
}
})
await nextTick()
return { user, onAddNode, onHoverNode, onRemoveFilter, onAddFilter }
return wrapper
}
async function setupFavorites(
@@ -72,10 +60,18 @@ describe('NodeSearchContent', () => {
) {
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
const result = await renderComponent()
await result.user.click(screen.getByTestId('category-favorites'))
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
return result
return wrapper
}
function getResultItems(wrapper: VueWrapper) {
return wrapper.findAll('[data-testid="result-item"]')
}
function getNodeItems(wrapper: VueWrapper) {
return wrapper.findAll('.node-item')
}
describe('category selection', () => {
@@ -92,11 +88,11 @@ describe('NodeSearchContent', () => {
useNodeDefStore().nodeDefsByName['FrequentNode']
])
await renderComponent()
const wrapper = await createWrapper()
const items = screen.getAllByTestId('node-item')
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
expect(items[0].text()).toContain('Frequent Node')
})
it('should show only bookmarked nodes when Favorites is selected', async () => {
@@ -114,13 +110,13 @@ describe('NodeSearchContent', () => {
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
)
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-favorites'))
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
const items = screen.getAllByTestId('node-item')
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Bookmarked')
expect(items[0].text()).toContain('Bookmarked')
})
it('should show empty state when no bookmarks exist', async () => {
@@ -129,11 +125,11 @@ describe('NodeSearchContent', () => {
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-favorites'))
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
expect(screen.getByText('No results')).toBeInTheDocument()
expect(wrapper.text()).toContain('No results')
})
it('should show only CustomNodes when Extensions is selected', async () => {
@@ -158,13 +154,13 @@ describe('NodeSearchContent', () => {
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
).toBe(NodeSourceType.CustomNodes)
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-extensions'))
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-extensions"]').trigger('click')
await nextTick()
const items = screen.getAllByTestId('node-item')
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Custom Node')
expect(items[0].text()).toContain('Custom Node')
})
it('should hide Essentials category when no essential nodes exist', async () => {
@@ -175,10 +171,10 @@ describe('NodeSearchContent', () => {
})
])
await renderComponent()
expect(
screen.queryByTestId('category-essentials')
).not.toBeInTheDocument()
const wrapper = await createWrapper()
expect(wrapper.find('[data-testid="category-essentials"]').exists()).toBe(
false
)
})
it('should show only essential nodes when Essentials is selected', async () => {
@@ -195,13 +191,13 @@ describe('NodeSearchContent', () => {
])
await nextTick()
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-essentials'))
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-essentials"]').trigger('click')
await nextTick()
const items = screen.getAllByTestId('node-item')
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
expect(items[0].text()).toContain('Essential Node')
})
it('should include subcategory nodes when parent category is selected', async () => {
@@ -223,11 +219,11 @@ describe('NodeSearchContent', () => {
})
])
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-sampling'))
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-sampling"]').trigger('click')
await nextTick()
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
const texts = getNodeItems(wrapper).map((i) => i.text())
expect(texts).toHaveLength(2)
expect(texts).toContain('KSampler')
expect(texts).toContain('KSampler Advanced')
@@ -249,18 +245,18 @@ describe('NodeSearchContent', () => {
})
])
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-sampling'))
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-sampling"]').trigger('click')
await nextTick()
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
expect(getNodeItems(wrapper)).toHaveLength(1)
const input = screen.getByRole('combobox')
await user.type(input, 'Load')
const input = wrapper.find('input[type="text"]')
await input.setValue('Load')
await nextTick()
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
expect(texts.some((t) => t?.includes('Load Checkpoint'))).toBe(true)
const texts = getNodeItems(wrapper).map((i) => i.text())
expect(texts.some((t) => t.includes('Load Checkpoint'))).toBe(true)
})
it('should clear search query when category changes', async () => {
@@ -268,58 +264,56 @@ describe('NodeSearchContent', () => {
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
])
const { user } = await renderComponent()
const wrapper = await createWrapper()
const input = screen.getByRole('combobox')
await user.type(input, 'test query')
const input = wrapper.find('input[type="text"]')
await input.setValue('test query')
await nextTick()
expect(input).toHaveValue('test query')
expect((input.element as HTMLInputElement).value).toBe('test query')
await user.click(screen.getByTestId('category-favorites'))
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
expect(input).toHaveValue('')
expect((input.element as HTMLInputElement).value).toBe('')
})
it('should reset selected index when search query changes', async () => {
const { user } = await setupFavorites([
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const input = screen.getByRole('combobox')
await user.click(input)
await user.keyboard('{ArrowDown}')
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
'aria-selected',
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await user.type(input, 'Node')
await input.setValue('Node')
await nextTick()
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
'aria-selected',
expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
})
it('should reset selected index when category changes', async () => {
const { user } = await setupFavorites([
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
await user.click(screen.getByRole('combobox'))
await user.keyboard('{ArrowDown}')
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
await user.click(screen.getByTestId('category-most-relevant'))
await wrapper
.find('[data-testid="category-most-relevant"]')
.trigger('click')
await nextTick()
await user.click(screen.getByTestId('category-favorites'))
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
'aria-selected',
expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
})
@@ -327,105 +321,106 @@ describe('NodeSearchContent', () => {
describe('keyboard and mouse interaction', () => {
it('should navigate results with ArrowDown/ArrowUp and clamp to bounds', async () => {
const { user } = await setupFavorites([
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' },
{ name: 'Node3', display_name: 'Node Three' }
])
await user.click(screen.getByRole('combobox'))
const input = wrapper.find('input[type="text"]')
const selectedIndex = () =>
screen
.getAllByTestId('result-item')
.findIndex((r) => r.getAttribute('aria-selected') === 'true')
getResultItems(wrapper).findIndex(
(r) => r.attributes('aria-selected') === 'true'
)
expect(selectedIndex()).toBe(0)
await user.keyboard('{ArrowDown}')
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(selectedIndex()).toBe(1)
await user.keyboard('{ArrowDown}')
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(selectedIndex()).toBe(2)
await user.keyboard('{ArrowUp}')
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(selectedIndex()).toBe(1)
// Navigate to first, then try going above — should clamp
await user.keyboard('{ArrowUp}')
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(selectedIndex()).toBe(0)
await user.keyboard('{ArrowUp}')
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(selectedIndex()).toBe(0)
})
it('should select current result with Enter key', async () => {
const { user, onAddNode } = await setupFavorites([
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Enter}')
await wrapper
.find('input[type="text"]')
.trigger('keydown', { key: 'Enter' })
await nextTick()
expect(onAddNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'TestNode' })
)
expect(wrapper.emitted('addNode')).toBeTruthy()
expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
name: 'TestNode'
})
})
it('should select item on hover', async () => {
const { user } = await setupFavorites([
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const results = screen.getAllByTestId('result-item')
await user.hover(results[1])
const results = getResultItems(wrapper)
await results[1].trigger('mouseenter')
await nextTick()
expect(results[1]).toHaveAttribute('aria-selected', 'true')
expect(results[1].attributes('aria-selected')).toBe('true')
})
it('should add node on click', async () => {
const { user, onAddNode } = await setupFavorites([
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await user.click(screen.getAllByTestId('result-item')[0])
await getResultItems(wrapper)[0].trigger('click')
await nextTick()
expect(onAddNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'TestNode' }),
expect.any(PointerEvent)
)
expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
name: 'TestNode'
})
})
})
describe('hoverNode emission', () => {
it('should emit hoverNode with the currently selected node', async () => {
const { onHoverNode } = await setupFavorites([
const wrapper = await setupFavorites([
{ name: 'HoverNode', display_name: 'Hover Node' }
])
const calls = onHoverNode.mock.calls
expect(calls[calls.length - 1][0]).toMatchObject({
const emitted = wrapper.emitted('hoverNode')!
expect(emitted[emitted.length - 1][0]).toMatchObject({
name: 'HoverNode'
})
})
it('should emit null hoverNode when no results', async () => {
const { user, onHoverNode } = await renderComponent()
const wrapper = await createWrapper()
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
await user.click(screen.getByTestId('category-favorites'))
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await nextTick()
const calls = onHoverNode.mock.calls
expect(calls[calls.length - 1][0]).toBeNull()
const emitted = wrapper.emitted('hoverNode')!
expect(emitted[emitted.length - 1][0]).toBeNull()
})
})
@@ -439,7 +434,7 @@ describe('NodeSearchContent', () => {
})
])
await renderComponent({
const wrapper = await createWrapper({
filters: [
{
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
@@ -448,7 +443,9 @@ describe('NodeSearchContent', () => {
]
})
expect(screen.getAllByTestId('filter-chip').length).toBeGreaterThan(0)
expect(
wrapper.findAll('[data-testid="filter-chip"]').length
).toBeGreaterThan(0)
})
})
@@ -474,41 +471,42 @@ describe('NodeSearchContent', () => {
it('should emit removeFilter on backspace', async () => {
const filters = createFilters(1)
const { user, onRemoveFilter } = await renderComponent({ filters })
const wrapper = await createWrapper({ filters })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Backspace}')
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'Backspace' })
await nextTick()
await user.keyboard('{Backspace}')
await input.trigger('keydown', { key: 'Backspace' })
await nextTick()
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
expect(onRemoveFilter).toHaveBeenCalledWith(
expect.objectContaining({ value: 'IMAGE' })
)
expect(wrapper.emitted('removeFilter')).toHaveLength(1)
expect(wrapper.emitted('removeFilter')![0][0]).toMatchObject({
value: 'IMAGE'
})
})
it('should not interact with chips when no filters exist', async () => {
const { user, onRemoveFilter } = await renderComponent({ filters: [] })
const wrapper = await createWrapper({ filters: [] })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Backspace}')
const input = wrapper.find('input[type="text"]')
await input.trigger('keydown', { key: 'Backspace' })
await nextTick()
expect(onRemoveFilter).not.toHaveBeenCalled()
expect(wrapper.emitted('removeFilter')).toBeUndefined()
})
it('should remove chip when clicking its delete button', async () => {
const filters = createFilters(1)
const { user, onRemoveFilter } = await renderComponent({ filters })
const wrapper = await createWrapper({ filters })
await user.click(screen.getByTestId('chip-delete'))
const deleteBtn = wrapper.find('[data-testid="chip-delete"]')
await deleteBtn.trigger('click')
await nextTick()
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
expect(onRemoveFilter).toHaveBeenCalledWith(
expect.objectContaining({ value: 'IMAGE' })
)
expect(wrapper.emitted('removeFilter')).toHaveLength(1)
expect(wrapper.emitted('removeFilter')![0][0]).toMatchObject({
value: 'IMAGE'
})
})
})
@@ -536,46 +534,54 @@ describe('NodeSearchContent', () => {
])
}
function findFilterBarButton(label: string) {
return screen.getAllByRole('button').find((b) => b.textContent === label)
function findFilterBarButton(wrapper: VueWrapper, label: string) {
return wrapper
.findAll('button[aria-pressed]')
.find((b) => b.text() === label)
}
async function enterFilterMode(user: ReturnType<typeof userEvent.setup>) {
const btn = findFilterBarButton('Input')
expect(btn).toBeDefined()
await user.click(btn!)
async function enterFilterMode(wrapper: VueWrapper) {
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
}
function hasSidebar() {
return screen.queryByTestId('category-most-relevant') !== null
function getFilterOptions(wrapper: VueWrapper) {
return wrapper.findAll('[data-testid="filter-option"]')
}
function getFilterOptionTexts(wrapper: VueWrapper) {
return getFilterOptions(wrapper).map(
(o) =>
o
.findAll('span')[0]
?.text()
.replace(/^[•·]\s*/, '')
.trim() ?? ''
)
}
function hasSidebar(wrapper: VueWrapper) {
return wrapper.findComponent(NodeSearchCategorySidebar).exists()
}
it('should enter filter mode when a filter chip is selected', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
const wrapper = await createWrapper()
expect(hasSidebar()).toBe(true)
expect(hasSidebar(wrapper)).toBe(true)
await enterFilterMode(user)
await enterFilterMode(wrapper)
expect(hasSidebar()).toBe(false)
expect(screen.getAllByTestId('filter-option').length).toBeGreaterThan(0)
expect(hasSidebar(wrapper)).toBe(false)
expect(getFilterOptions(wrapper).length).toBeGreaterThan(0)
})
it('should show available filter options sorted alphabetically', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const texts = screen.getAllByTestId('filter-option').map(
(o) =>
/* eslint-disable testing-library/no-node-access */
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
?.replace(/^[•·]\s*/, '')
.trim() ?? ''
/* eslint-enable testing-library/no-node-access */
)
const texts = getFilterOptionTexts(wrapper)
expect(texts).toContain('IMAGE')
expect(texts).toContain('LATENT')
expect(texts).toContain('MODEL')
@@ -584,152 +590,140 @@ describe('NodeSearchContent', () => {
it('should filter options when typing in filter mode', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await user.type(screen.getByRole('combobox'), 'IMAGE')
await wrapper.find('input[type="text"]').setValue('IMAGE')
await nextTick()
const texts = screen.getAllByTestId('filter-option').map(
(o) =>
/* eslint-disable testing-library/no-node-access */
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
?.replace(/^[•·]\s*/, '')
.trim() ?? ''
/* eslint-enable testing-library/no-node-access */
)
const texts = getFilterOptionTexts(wrapper)
expect(texts).toContain('IMAGE')
expect(texts).not.toContain('MODEL')
})
it('should show no results when filter query has no matches', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await user.type(screen.getByRole('combobox'), 'NONEXISTENT_TYPE')
await wrapper.find('input[type="text"]').setValue('NONEXISTENT_TYPE')
await nextTick()
expect(screen.getByText('No results')).toBeInTheDocument()
expect(wrapper.text()).toContain('No results')
})
it('should emit addFilter when a filter option is clicked', async () => {
setupNodesWithTypes()
const { user, onAddFilter } = await renderComponent()
await enterFilterMode(user)
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const imageOption = screen
.getAllByTestId('filter-option')
.find((o) => o.textContent?.includes('IMAGE'))
await user.click(imageOption!)
const imageOption = getFilterOptions(wrapper).find((o) =>
o.text().includes('IMAGE')
)
await imageOption!.trigger('click')
await nextTick()
expect(onAddFilter).toHaveBeenCalledWith(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
)
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
})
it('should exit filter mode after applying a filter', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await user.click(screen.getAllByTestId('filter-option')[0])
await getFilterOptions(wrapper)[0].trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar()).toBe(true)
expect(hasSidebar(wrapper)).toBe(true)
})
it('should emit addFilter when Enter is pressed on selected option', async () => {
setupNodesWithTypes()
const { user, onAddFilter } = await renderComponent()
await enterFilterMode(user)
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Enter}')
await wrapper
.find('input[type="text"]')
.trigger('keydown', { key: 'Enter' })
await nextTick()
expect(onAddFilter).toHaveBeenCalledWith(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
)
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
})
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await user.click(screen.getByRole('combobox'))
const input = wrapper.find('input[type="text"]')
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
'aria-selected',
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
await user.keyboard('{ArrowDown}')
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(screen.getAllByTestId('filter-option')[1]).toHaveAttribute(
'aria-selected',
expect(getFilterOptions(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await user.keyboard('{ArrowUp}')
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
'aria-selected',
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
})
it('should toggle filter mode off when same chip is clicked again', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await user.click(findFilterBarButton('Input')!)
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar()).toBe(true)
expect(hasSidebar(wrapper)).toBe(true)
})
it('should reset filter query when re-entering filter mode', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const input = screen.getByRole('combobox')
await user.type(input, 'IMAGE')
const input = wrapper.find('input[type="text"]')
await input.setValue('IMAGE')
await nextTick()
await user.click(findFilterBarButton('Input')!)
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
await nextTick()
await enterFilterMode(user)
await enterFilterMode(wrapper)
expect(input).toHaveValue('')
expect((input.element as HTMLInputElement).value).toBe('')
})
it('should exit filter mode when cancel button is clicked', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
expect(hasSidebar()).toBe(false)
expect(hasSidebar(wrapper)).toBe(false)
await user.click(screen.getByTestId('cancel-filter'))
const cancelBtn = wrapper.find('[data-testid="cancel-filter"]')
await cancelBtn.trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar()).toBe(true)
expect(hasSidebar(wrapper)).toBe(true)
})
})
})

View File

@@ -1,5 +1,4 @@
import { render, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -31,59 +30,54 @@ describe(NodeSearchFilterBar, () => {
])
})
async function createRender(props = {}) {
const user = userEvent.setup()
const onSelectChip = vi.fn()
const { container } = render(NodeSearchFilterBar, {
props: { onSelectChip, ...props },
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchFilterBar, {
props,
global: { plugins: [testI18n] }
})
await nextTick()
const view = within(container as HTMLElement)
return { user, onSelectChip, view }
return wrapper
}
it('should render all filter chips', async () => {
const { view } = await createRender()
const wrapper = await createWrapper()
const buttons = view.getAllByRole('button')
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(6)
expect(buttons[0]).toHaveTextContent('Blueprints')
expect(buttons[1]).toHaveTextContent('Partner Nodes')
expect(buttons[2]).toHaveTextContent('Essentials')
expect(buttons[3]).toHaveTextContent('Extensions')
expect(buttons[4]).toHaveTextContent('Input')
expect(buttons[5]).toHaveTextContent('Output')
expect(buttons[0].text()).toBe('Blueprints')
expect(buttons[1].text()).toBe('Partner Nodes')
expect(buttons[2].text()).toBe('Essentials')
expect(buttons[3].text()).toBe('Extensions')
expect(buttons[4].text()).toBe('Input')
expect(buttons[5].text()).toBe('Output')
})
it('should mark active chip as pressed when activeChipKey matches', async () => {
const { view } = await createRender({ activeChipKey: 'input' })
const wrapper = await createWrapper({ activeChipKey: 'input' })
expect(view.getByRole('button', { name: 'Input' })).toHaveAttribute(
'aria-pressed',
'true'
)
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
expect(inputBtn?.attributes('aria-pressed')).toBe('true')
})
it('should not mark chips as pressed when activeChipKey does not match', async () => {
const { view } = await createRender({ activeChipKey: null })
const wrapper = await createWrapper({ activeChipKey: null })
view.getAllByRole('button').forEach((btn) => {
expect(btn).toHaveAttribute('aria-pressed', 'false')
wrapper.findAll('button').forEach((btn) => {
expect(btn.attributes('aria-pressed')).toBe('false')
})
})
it('should emit selectChip with chip data when clicked', async () => {
const { user, onSelectChip, view } = await createRender()
const wrapper = await createWrapper()
await user.click(view.getByRole('button', { name: 'Input' }))
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
await inputBtn?.trigger('click')
expect(onSelectChip).toHaveBeenCalledWith(
expect.objectContaining({
key: 'input',
label: 'Input',
filter: expect.anything()
})
)
const emitted = wrapper.emitted('selectChip')!
expect(emitted[0][0]).toMatchObject({
key: 'input',
label: 'Input',
filter: expect.anything()
})
})
})

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
@@ -60,7 +59,7 @@ describe('NodeSearchInput', () => {
vi.restoreAllMocks()
})
function createRender(
function createWrapper(
props: Partial<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
@@ -68,128 +67,101 @@ describe('NodeSearchInput', () => {
filterQuery: string
}> = {}
) {
const user = userEvent.setup()
const onUpdateSearchQuery = vi.fn()
const onUpdateFilterQuery = vi.fn()
const onCancelFilter = vi.fn()
const onSelectCurrent = vi.fn()
const onNavigateDown = vi.fn()
const onNavigateUp = vi.fn()
render(NodeSearchInput, {
return mount(NodeSearchInput, {
props: {
filters: [],
activeFilter: null,
searchQuery: '',
filterQuery: '',
'onUpdate:searchQuery': onUpdateSearchQuery,
'onUpdate:filterQuery': onUpdateFilterQuery,
onCancelFilter,
onSelectCurrent,
onNavigateDown,
onNavigateUp,
...props
},
global: { plugins: [testI18n] }
})
return {
user,
onUpdateSearchQuery,
onUpdateFilterQuery,
onCancelFilter,
onSelectCurrent,
onNavigateDown,
onNavigateUp
}
}
it('should route input to searchQuery when no active filter', async () => {
const { user, onUpdateSearchQuery } = createRender()
await user.type(screen.getByRole('combobox'), 'test search')
const wrapper = createWrapper()
await wrapper.find('input').setValue('test search')
expect(onUpdateSearchQuery).toHaveBeenLastCalledWith('test search')
expect(wrapper.emitted('update:searchQuery')![0]).toEqual(['test search'])
})
it('should route input to filterQuery when active filter is set', async () => {
const { user, onUpdateFilterQuery, onUpdateSearchQuery } = createRender({
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
await user.type(screen.getByRole('combobox'), 'IMAGE')
await wrapper.find('input').setValue('IMAGE')
expect(onUpdateFilterQuery).toHaveBeenLastCalledWith('IMAGE')
expect(onUpdateSearchQuery).not.toHaveBeenCalled()
expect(wrapper.emitted('update:filterQuery')![0]).toEqual(['IMAGE'])
expect(wrapper.emitted('update:searchQuery')).toBeUndefined()
})
it('should show filter label placeholder when active filter is set', () => {
createRender({
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
expect(screen.getByRole('combobox')).toHaveAttribute(
'placeholder',
expect.stringContaining('input')
)
expect(
(wrapper.find('input').element as HTMLInputElement).placeholder
).toContain('input')
})
it('should show add node placeholder when no active filter', () => {
createRender()
const wrapper = createWrapper()
expect(screen.getByRole('combobox')).toHaveAttribute(
'placeholder',
expect.stringContaining('Add a node')
)
expect(
(wrapper.find('input').element as HTMLInputElement).placeholder
).toContain('Add a node')
})
it('should hide filter chips when active filter is set', () => {
createRender({
const wrapper = createWrapper({
filters: [createFilter('input', 'IMAGE')],
activeFilter: createActiveFilter('Input')
})
expect(screen.queryAllByTestId('filter-chip')).toHaveLength(0)
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(0)
})
it('should show filter chips when no active filter', () => {
createRender({
const wrapper = createWrapper({
filters: [createFilter('input', 'IMAGE')]
})
expect(screen.getAllByTestId('filter-chip')).toHaveLength(1)
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(1)
})
it('should emit cancelFilter when cancel button is clicked', async () => {
const { user, onCancelFilter } = createRender({
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
await user.click(screen.getByTestId('cancel-filter'))
await wrapper.find('[data-testid="cancel-filter"]').trigger('click')
expect(onCancelFilter).toHaveBeenCalledOnce()
expect(wrapper.emitted('cancelFilter')).toHaveLength(1)
})
it('should emit selectCurrent on Enter', async () => {
const { user, onSelectCurrent } = createRender()
const wrapper = createWrapper()
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Enter}')
await wrapper.find('input').trigger('keydown', { key: 'Enter' })
expect(onSelectCurrent).toHaveBeenCalledOnce()
expect(wrapper.emitted('selectCurrent')).toHaveLength(1)
})
it('should emit navigateDown on ArrowDown', async () => {
const { user, onNavigateDown } = createRender()
const wrapper = createWrapper()
await user.click(screen.getByRole('combobox'))
await user.keyboard('{ArrowDown}')
await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' })
expect(onNavigateDown).toHaveBeenCalledOnce()
expect(wrapper.emitted('navigateDown')).toHaveLength(1)
})
it('should emit navigateUp on ArrowUp', async () => {
const { user, onNavigateUp } = createRender()
const wrapper = createWrapper()
await user.click(screen.getByRole('combobox'))
await user.keyboard('{ArrowUp}')
await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
expect(onNavigateUp).toHaveBeenCalledOnce()
expect(wrapper.emitted('navigateUp')).toHaveLength(1)
})
})

View File

@@ -1,5 +1,4 @@
/* eslint-disable vue/one-component-per-file */
import { render, fireEvent } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'
@@ -32,32 +31,6 @@ const VirtualGridStub = defineComponent({
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
})
const AssetsListItemStub = defineComponent({
name: 'AssetsListItem',
props: {
previewUrl: { type: String, default: '' },
isVideoPreview: { type: Boolean, default: false },
previewAlt: { type: String, default: '' },
iconName: { type: String, default: '' },
iconAriaLabel: { type: String, default: '' },
iconClass: { type: String, default: '' },
iconWrapperClass: { type: String, default: '' },
primaryText: { type: String, default: '' },
secondaryText: { type: String, default: '' },
stackCount: { type: Number, default: 0 },
stackIndicatorLabel: { type: String, default: '' },
stackExpanded: { type: Boolean, default: false },
progressTotalPercent: { type: Number, default: undefined },
progressCurrentPercent: { type: Number, default: undefined }
},
template: `<div
class="assets-list-item-stub"
:data-preview-url="previewUrl"
:data-is-video-preview="isVideoPreview"
data-testid="assets-list-item"
><button data-testid="preview-click-trigger" @click="$emit('preview-click')" /><slot /></div>`
})
const buildAsset = (id: string, name: string): AssetItem =>
({
id,
@@ -70,27 +43,21 @@ const buildOutputItem = (asset: AssetItem): OutputStackListItem => ({
asset
})
function renderListView(
assetItems: OutputStackListItem[] = [],
props: Record<string, unknown> = {}
) {
return render(AssetsSidebarListView, {
const mountListView = (assetItems: OutputStackListItem[] = []) =>
mount(AssetsSidebarListView, {
props: {
assetItems,
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {},
...props
toggleStack: async () => {}
},
global: {
stubs: {
VirtualGrid: VirtualGridStub,
AssetsListItem: AssetsListItemStub
VirtualGrid: VirtualGridStub
}
}
})
}
describe('AssetsSidebarListView', () => {
it('marks mp4 assets as video previews', () => {
@@ -100,17 +67,14 @@ describe('AssetsSidebarListView', () => {
user_metadata: {}
} satisfies AssetItem
const { container } = renderListView([buildOutputItem(videoAsset)])
const wrapper = mountListView([buildOutputItem(videoAsset)])
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const stubs = container.querySelectorAll('[data-testid="assets-list-item"]')
const assetListItem = stubs[stubs.length - 1]
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
expect(assetListItem?.getAttribute('data-preview-url')).toBe(
'/api/view/clip.mp4'
)
expect(assetListItem?.getAttribute('data-is-video-preview')).toBe('true')
expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4')
expect(assetListItem?.props('isVideoPreview')).toBe(true)
})
it('uses icon fallback for text assets even when preview_url exists', () => {
@@ -120,15 +84,14 @@ describe('AssetsSidebarListView', () => {
user_metadata: {}
} satisfies AssetItem
const { container } = renderListView([buildOutputItem(textAsset)])
const wrapper = mountListView([buildOutputItem(textAsset)])
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const stubs = container.querySelectorAll('[data-testid="assets-list-item"]')
const assetListItem = stubs[stubs.length - 1]
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
expect(assetListItem?.getAttribute('data-preview-url')).toBe('')
expect(assetListItem?.getAttribute('data-is-video-preview')).toBe('false')
expect(assetListItem?.props('previewUrl')).toBe('')
expect(assetListItem?.props('isVideoPreview')).toBe(false)
})
it('emits preview-asset when item preview is clicked', async () => {
@@ -138,19 +101,16 @@ describe('AssetsSidebarListView', () => {
user_metadata: {}
} satisfies AssetItem
const onPreviewAsset = vi.fn()
const { container } = renderListView([buildOutputItem(imageAsset)], {
'onPreview-asset': onPreviewAsset
})
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const trigger = container.querySelector(
'[data-testid="preview-click-trigger"]'
)!
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.click(trigger)
expect(assetListItem).toBeDefined()
expect(onPreviewAsset).toHaveBeenCalledWith(imageAsset)
assetListItem!.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
it('emits preview-asset when item is double-clicked', async () => {
@@ -160,16 +120,15 @@ describe('AssetsSidebarListView', () => {
user_metadata: {}
} satisfies AssetItem
const onPreviewAsset = vi.fn()
const { container } = renderListView([buildOutputItem(imageAsset)], {
'onPreview-asset': onPreviewAsset
})
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const stub = container.querySelector('[data-testid="assets-list-item"]')!
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.dblClick(stub)
expect(assetListItem).toBeDefined()
expect(onPreviewAsset).toHaveBeenCalledWith(imageAsset)
await assetListItem!.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
})

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