mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-09 13:59:58 +00:00
Compare commits
2 Commits
fix/subgra
...
version-bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d54fce86a | ||
|
|
65999d0fc1 |
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
© {{ new Date().getFullYear() }}
|
||||
{{ t('footer.copyright', locale) }}
|
||||
© {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
|
||||
</p>
|
||||
|
||||
<!-- Social icons -->
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -9,31 +8,6 @@ const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const getPromotedHostWidgetValues = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) => {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -109,35 +83,5 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
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.nextFrame()
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.0",
|
||||
"version": "1.43.16",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
627
pnpm-lock.yaml
generated
627
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('<strong>')
|
||||
// The component should render the HTML, not escape it
|
||||
expect(description.html()).toContain('<strong>bold</strong>')
|
||||
expect(description.html()).not.toContain('<strong>')
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import NodeLibrarySidebarTabV2 from './NodeLibrarySidebarTabV2.vue'
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
@@ -92,8 +91,8 @@ describe('NodeLibrarySidebarTabV2', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
return render(NodeLibrarySidebarTabV2, {
|
||||
function mountComponent() {
|
||||
return mount(NodeLibrarySidebarTabV2, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ stubActions: false }), i18n],
|
||||
stubs: {
|
||||
@@ -104,23 +103,25 @@ describe('NodeLibrarySidebarTabV2', () => {
|
||||
}
|
||||
|
||||
it('should render with tabs', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const triggers = screen.getAllByRole('tab')
|
||||
const triggers = wrapper.findAll('[role="tab"]')
|
||||
expect(triggers).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render search box', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(screen.getByTestId('search-box')).toBeInTheDocument()
|
||||
expect(wrapper.find('[data-testid="search-box"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should render only the selected panel', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(screen.getByTestId('essential-panel')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('all-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('blueprints-panel')).not.toBeInTheDocument()
|
||||
expect(wrapper.find('[data-testid="essential-panel"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="all-panel"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="blueprints-panel"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fireEvent, 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 { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -26,9 +25,7 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-preview" data-testid="node-preview" />'
|
||||
}
|
||||
default: { template: '<div class="mock-preview" />' }
|
||||
}))
|
||||
|
||||
describe('EssentialNodeCard', () => {
|
||||
@@ -55,93 +52,69 @@ describe('EssentialNodeCard', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
function mountComponent(
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl> = createMockNode()
|
||||
) {
|
||||
const onClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(EssentialNodeCard, {
|
||||
props: { node, onClick },
|
||||
return mount(EssentialNodeCard, {
|
||||
props: { node },
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user, onClick, container }
|
||||
}
|
||||
|
||||
function getCard(container: Element) {
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
const card = container.querySelector('[data-node-name]') as HTMLElement
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
return card
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should display the node display_name', () => {
|
||||
renderComponent(createMockNode({ display_name: 'Load Image' }))
|
||||
expect(screen.getAllByText('Load Image').length).toBeGreaterThan(0)
|
||||
const wrapper = mountComponent(
|
||||
createMockNode({ display_name: 'Load Image' })
|
||||
)
|
||||
expect(wrapper.text()).toContain('Load Image')
|
||||
})
|
||||
|
||||
it('should set data-node-name attribute', () => {
|
||||
const { container } = renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createMockNode({ display_name: 'Save Image' })
|
||||
)
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
const card = container.querySelector('[data-node-name]')
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(card).toHaveAttribute('data-node-name', 'Save Image')
|
||||
const card = wrapper.find('[data-node-name]')
|
||||
expect(card.attributes('data-node-name')).toBe('Save Image')
|
||||
})
|
||||
|
||||
it('should be draggable', () => {
|
||||
const { container } = renderComponent()
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
const card = container.querySelector('[draggable]')
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(card).toHaveAttribute('draggable', 'true')
|
||||
const wrapper = mountComponent()
|
||||
const card = wrapper.find('[draggable]')
|
||||
expect(card.attributes('draggable')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('icon generation', () => {
|
||||
it('should use override icon for LoadImage', () => {
|
||||
const { container } = renderComponent(
|
||||
createMockNode({ name: 'LoadImage' })
|
||||
)
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
const icon = container.querySelector('i')
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(icon).toHaveClass('icon-s1.3-[lucide--image-up]')
|
||||
const wrapper = mountComponent(createMockNode({ name: 'LoadImage' }))
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain('icon-s1.3-[lucide--image-up]')
|
||||
})
|
||||
|
||||
it('should use override icon for SaveImage', () => {
|
||||
const { container } = renderComponent(
|
||||
createMockNode({ name: 'SaveImage' })
|
||||
)
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
const icon = container.querySelector('i')
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(icon).toHaveClass('icon-s1.3-[lucide--image-down]')
|
||||
const wrapper = mountComponent(createMockNode({ name: 'SaveImage' }))
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain('icon-s1.3-[lucide--image-down]')
|
||||
})
|
||||
|
||||
it('should use override icon for ImageCrop', () => {
|
||||
const { container } = renderComponent(
|
||||
createMockNode({ name: 'ImageCrop' })
|
||||
)
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
const icon = container.querySelector('i')
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(icon).toHaveClass('icon-s1.3-[lucide--crop]')
|
||||
const wrapper = mountComponent(createMockNode({ name: 'ImageCrop' }))
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain('icon-s1.3-[lucide--crop]')
|
||||
})
|
||||
|
||||
it('should use kebab-case for complex node names', () => {
|
||||
const { container } = renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createMockNode({ name: 'RecraftRemoveBackgroundNode' })
|
||||
)
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
const icon = container.querySelector('i')
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(icon).toHaveClass('icon-[comfy--recraft-remove-background-node]')
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain(
|
||||
'icon-[comfy--recraft-remove-background-node]'
|
||||
)
|
||||
})
|
||||
|
||||
it('should use default node icon when nodeDef has no name', () => {
|
||||
@@ -153,22 +126,21 @@ describe('EssentialNodeCard', () => {
|
||||
totalLeaves: 1,
|
||||
data: undefined
|
||||
}
|
||||
const { container } = renderComponent(node)
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
const icon = container.querySelector('i')
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(icon).toHaveClass('icon-[comfy--node]')
|
||||
const wrapper = mountComponent(node)
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.classes()).toContain('icon-[comfy--node]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('should emit click event when clicked', async () => {
|
||||
const node = createMockNode()
|
||||
const { user, onClick, container } = renderComponent(node)
|
||||
const wrapper = mountComponent(node)
|
||||
|
||||
await user.click(getCard(container))
|
||||
await wrapper.find('div').trigger('click')
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith(node)
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
expect(wrapper.emitted('click')?.[0]).toEqual([node])
|
||||
})
|
||||
|
||||
it('should not emit click when nodeDef is undefined', async () => {
|
||||
@@ -180,27 +152,29 @@ describe('EssentialNodeCard', () => {
|
||||
totalLeaves: 1,
|
||||
data: undefined
|
||||
}
|
||||
const { user, onClick, container } = renderComponent(node)
|
||||
const wrapper = mountComponent(node)
|
||||
|
||||
await user.click(getCard(container))
|
||||
await wrapper.find('div').trigger('click')
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('click')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag and drop', () => {
|
||||
it('should call startDrag on dragstart', async () => {
|
||||
const { container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const card = wrapper.find('div')
|
||||
|
||||
await fireEvent.dragStart(getCard(container))
|
||||
await card.trigger('dragstart')
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleNativeDrop on dragend', async () => {
|
||||
const { container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const card = wrapper.find('div')
|
||||
|
||||
await fireEvent.dragEnd(getCard(container))
|
||||
await card.trigger('dragend')
|
||||
|
||||
expect(mockHandleNativeDrop).toHaveBeenCalled()
|
||||
})
|
||||
@@ -208,21 +182,23 @@ describe('EssentialNodeCard', () => {
|
||||
|
||||
describe('hover preview', () => {
|
||||
it('should show preview on mouseenter', async () => {
|
||||
const { user, container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const card = wrapper.find('div')
|
||||
|
||||
await user.hover(getCard(container))
|
||||
await card.trigger('mouseenter')
|
||||
|
||||
expect(screen.getByTestId('node-preview')).toBeInTheDocument()
|
||||
expect(wrapper.find('teleport-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide preview after mouseleave', async () => {
|
||||
const { user, container } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
const card = wrapper.find('div')
|
||||
|
||||
await user.hover(getCard(container))
|
||||
expect(screen.getByTestId('node-preview')).toBeInTheDocument()
|
||||
await card.trigger('mouseenter')
|
||||
expect(wrapper.find('teleport-stub').exists()).toBe(true)
|
||||
|
||||
await user.unhover(getCard(container))
|
||||
expect(screen.queryByTestId('node-preview')).not.toBeInTheDocument()
|
||||
await card.trigger('mouseleave')
|
||||
expect(wrapper.find('teleport-stub').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, waitFor } from '@testing-library/vue'
|
||||
import { ref } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -80,7 +80,7 @@ describe('EssentialNodesPanel', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
function mountComponent(
|
||||
root = createMockRoot(),
|
||||
expandedKeys: string[] = [],
|
||||
flatNodes: RenderedTreeExplorerNode<ComfyNodeDefImpl>[] = []
|
||||
@@ -93,7 +93,7 @@ describe('EssentialNodesPanel', () => {
|
||||
return { root, flatNodes, keys }
|
||||
}
|
||||
}
|
||||
return render(WrapperComponent, {
|
||||
return mount(WrapperComponent, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
@@ -112,9 +112,6 @@ describe('EssentialNodesPanel', () => {
|
||||
},
|
||||
CollapsibleContent: {
|
||||
template: '<div class="collapsible-content"><slot /></div>'
|
||||
},
|
||||
EssentialNodeCard: {
|
||||
template: '<div data-testid="essential-node-card" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,61 +120,54 @@ describe('EssentialNodesPanel', () => {
|
||||
|
||||
describe('folder rendering', () => {
|
||||
it('should render all top-level folders', () => {
|
||||
const { container } = renderComponent()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelectorAll('.collapsible-trigger')).toHaveLength(3)
|
||||
const wrapper = mountComponent()
|
||||
const triggers = wrapper.findAll('.collapsible-trigger')
|
||||
expect(triggers).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should display folder labels', () => {
|
||||
const { container } = renderComponent()
|
||||
expect(container.textContent).toContain('images')
|
||||
expect(container.textContent).toContain('video')
|
||||
expect(container.textContent).toContain('audio')
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('images')
|
||||
expect(wrapper.text()).toContain('video')
|
||||
expect(wrapper.text()).toContain('audio')
|
||||
})
|
||||
})
|
||||
|
||||
describe('default expansion', () => {
|
||||
it('should expand all folders by default when expandedKeys is empty', async () => {
|
||||
const { container } = renderComponent(createMockRoot(), [])
|
||||
const wrapper = mountComponent(createMockRoot(), [])
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const roots = container.querySelectorAll('.collapsible-root')
|
||||
expect(roots).toHaveLength(3)
|
||||
expect(roots[0].getAttribute('data-state')).toBe('open')
|
||||
expect(roots[1].getAttribute('data-state')).toBe('open')
|
||||
expect(roots[2].getAttribute('data-state')).toBe('open')
|
||||
})
|
||||
const roots = wrapper.findAll('.collapsible-root')
|
||||
expect(roots[0].attributes('data-state')).toBe('open')
|
||||
expect(roots[1].attributes('data-state')).toBe('open')
|
||||
expect(roots[2].attributes('data-state')).toBe('open')
|
||||
})
|
||||
|
||||
it('should respect provided expandedKeys', async () => {
|
||||
const { container } = renderComponent(createMockRoot(), ['folder-audio'])
|
||||
const wrapper = mountComponent(createMockRoot(), ['folder-audio'])
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const roots = container.querySelectorAll('.collapsible-root')
|
||||
expect(roots).toHaveLength(3)
|
||||
expect(roots[0].getAttribute('data-state')).toBe('closed')
|
||||
expect(roots[1].getAttribute('data-state')).toBe('closed')
|
||||
expect(roots[2].getAttribute('data-state')).toBe('open')
|
||||
})
|
||||
const roots = wrapper.findAll('.collapsible-root')
|
||||
expect(roots[0].attributes('data-state')).toBe('closed')
|
||||
expect(roots[1].attributes('data-state')).toBe('closed')
|
||||
expect(roots[2].attributes('data-state')).toBe('open')
|
||||
})
|
||||
|
||||
it('should expand all provided keys', async () => {
|
||||
const { container } = renderComponent(createMockRoot(), [
|
||||
const wrapper = mountComponent(createMockRoot(), [
|
||||
'folder-images',
|
||||
'folder-video',
|
||||
'folder-audio'
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const roots = container.querySelectorAll('.collapsible-root')
|
||||
expect(roots).toHaveLength(3)
|
||||
expect(roots[0].getAttribute('data-state')).toBe('open')
|
||||
expect(roots[1].getAttribute('data-state')).toBe('open')
|
||||
expect(roots[2].getAttribute('data-state')).toBe('open')
|
||||
})
|
||||
const roots = wrapper.findAll('.collapsible-root')
|
||||
expect(roots[0].attributes('data-state')).toBe('open')
|
||||
expect(roots[1].attributes('data-state')).toBe('open')
|
||||
expect(roots[2].attributes('data-state')).toBe('open')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -197,24 +187,21 @@ describe('EssentialNodesPanel', () => {
|
||||
]
|
||||
}
|
||||
|
||||
const { container } = renderComponent(root, [])
|
||||
const wrapper = mountComponent(root, [])
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const roots = container.querySelectorAll('.collapsible-root')
|
||||
expect(roots).toHaveLength(1)
|
||||
expect(roots[0].getAttribute('data-state')).toBe('open')
|
||||
})
|
||||
const roots = wrapper.findAll('.collapsible-root')
|
||||
expect(roots).toHaveLength(1)
|
||||
expect(roots[0].attributes('data-state')).toBe('open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('node cards', () => {
|
||||
it('should render node cards for each node in expanded folders', () => {
|
||||
const { container } = renderComponent(createMockRoot(), ['folder-images'])
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const cards = container.querySelectorAll(
|
||||
'[data-testid="essential-node-card"]'
|
||||
)
|
||||
const wrapper = mountComponent(createMockRoot(), ['folder-images'])
|
||||
const cards = wrapper.findAllComponents({ name: 'EssentialNodeCard' })
|
||||
expect(cards.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
@@ -226,15 +213,11 @@ describe('EssentialNodesPanel', () => {
|
||||
createMockNode('LoadImage'),
|
||||
createMockNode('SaveImage')
|
||||
]
|
||||
const { container } = renderComponent(createMockRoot(), [], flatNodes)
|
||||
const wrapper = mountComponent(createMockRoot(), [], flatNodes)
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelectorAll('.collapsible-root')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.collapsible-root')).toHaveLength(0)
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const cards = container.querySelectorAll(
|
||||
'[data-testid="essential-node-card"]'
|
||||
)
|
||||
const cards = wrapper.findAllComponents({ name: 'EssentialNodeCard' })
|
||||
expect(cards).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
@@ -98,13 +99,16 @@ describe('MediaLightbox', () => {
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
})
|
||||
|
||||
const renderGallery = (props = {}) => {
|
||||
const onUpdateActiveIndex = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const { rerender, container } = render(MediaLightbox, {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const mountGallery = (props = {}) => {
|
||||
return mount(MediaLightbox, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
components: {
|
||||
@@ -119,118 +123,107 @@ describe('MediaLightbox', () => {
|
||||
props: {
|
||||
allGalleryItems: mockGalleryItems as ResultItemImpl[],
|
||||
activeIndex: 0,
|
||||
'onUpdate:activeIndex': onUpdateActiveIndex,
|
||||
...props
|
||||
},
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
attachTo: document.getElementById('app') || undefined
|
||||
})
|
||||
return { user, onUpdateActiveIndex, rerender, container }
|
||||
}
|
||||
|
||||
it('renders overlay with role="dialog" and aria-modal', async () => {
|
||||
renderGallery()
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
const dialog = wrapper.find('[role="dialog"]')
|
||||
expect(dialog.exists()).toBe(true)
|
||||
expect(dialog.attributes('aria-modal')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows navigation buttons when multiple items', async () => {
|
||||
renderGallery()
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByLabelText('Previous')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Next')).toBeInTheDocument()
|
||||
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides navigation buttons for single item', async () => {
|
||||
renderGallery({
|
||||
const wrapper = mountGallery({
|
||||
allGalleryItems: [mockGalleryItems[0]] as ResultItemImpl[]
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByLabelText('Previous')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Next')).not.toBeInTheDocument()
|
||||
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows gallery when activeIndex changes from -1', async () => {
|
||||
const { rerender, container } = renderGallery({ activeIndex: -1 })
|
||||
const wrapper = mountGallery({ activeIndex: -1 })
|
||||
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(container.querySelector('[data-mask]')).not.toBeInTheDocument()
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(wrapper.find('[data-mask]').exists()).toBe(false)
|
||||
|
||||
await rerender({
|
||||
allGalleryItems: mockGalleryItems as ResultItemImpl[],
|
||||
activeIndex: 0
|
||||
})
|
||||
await wrapper.setProps({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(container.querySelector('[data-mask]')).toBeInTheDocument()
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(wrapper.find('[data-mask]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits update:activeIndex with -1 when close button clicked', async () => {
|
||||
const { user, onUpdateActiveIndex } = renderGallery()
|
||||
const wrapper = mountGallery()
|
||||
await nextTick()
|
||||
|
||||
await user.click(screen.getByLabelText('Close'))
|
||||
await wrapper.find('[aria-label="Close"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(onUpdateActiveIndex).toHaveBeenCalledWith(-1)
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
|
||||
})
|
||||
|
||||
/* eslint-disable testing-library/prefer-user-event -- keyDown on dialog element for navigation, not text input */
|
||||
describe('keyboard navigation', () => {
|
||||
it('navigates to next item on ArrowRight', async () => {
|
||||
const { onUpdateActiveIndex } = renderGallery({ activeIndex: 0 })
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
await fireEvent.keyDown(screen.getByRole('dialog'), {
|
||||
key: 'ArrowRight'
|
||||
})
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(onUpdateActiveIndex).toHaveBeenCalledWith(1)
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([1])
|
||||
})
|
||||
|
||||
it('navigates to previous item on ArrowLeft', async () => {
|
||||
const { onUpdateActiveIndex } = renderGallery({ activeIndex: 1 })
|
||||
const wrapper = mountGallery({ activeIndex: 1 })
|
||||
await nextTick()
|
||||
|
||||
await fireEvent.keyDown(screen.getByRole('dialog'), {
|
||||
key: 'ArrowLeft'
|
||||
})
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'ArrowLeft' })
|
||||
await nextTick()
|
||||
|
||||
expect(onUpdateActiveIndex).toHaveBeenCalledWith(0)
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([0])
|
||||
})
|
||||
|
||||
it('wraps to last item on ArrowLeft from first', async () => {
|
||||
const { onUpdateActiveIndex } = renderGallery({ activeIndex: 0 })
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
await fireEvent.keyDown(screen.getByRole('dialog'), {
|
||||
key: 'ArrowLeft'
|
||||
})
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'ArrowLeft' })
|
||||
await nextTick()
|
||||
|
||||
expect(onUpdateActiveIndex).toHaveBeenCalledWith(2)
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([2])
|
||||
})
|
||||
|
||||
it('closes gallery on Escape', async () => {
|
||||
const { onUpdateActiveIndex } = renderGallery({ activeIndex: 0 })
|
||||
const wrapper = mountGallery({ activeIndex: 0 })
|
||||
await nextTick()
|
||||
|
||||
await fireEvent.keyDown(screen.getByRole('dialog'), {
|
||||
key: 'Escape'
|
||||
})
|
||||
await wrapper
|
||||
.find('[role="dialog"]')
|
||||
.trigger('keydown', { key: 'Escape' })
|
||||
await nextTick()
|
||||
|
||||
expect(onUpdateActiveIndex).toHaveBeenCalledWith(-1)
|
||||
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
|
||||
})
|
||||
})
|
||||
/* eslint-enable testing-library/prefer-user-event */
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -30,8 +30,8 @@ vi.mock('@vueuse/core', () => ({
|
||||
}))
|
||||
|
||||
describe('CompareSliderThumbnail', () => {
|
||||
const renderThumbnail = (props = {}) => {
|
||||
return render(CompareSliderThumbnail, {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(CompareSliderThumbnail, {
|
||||
props: {
|
||||
baseImageSrc: '/base-image.jpg',
|
||||
overlayImageSrc: '/overlay-image.jpg',
|
||||
@@ -43,35 +43,42 @@ describe('CompareSliderThumbnail', () => {
|
||||
}
|
||||
|
||||
it('renders both base and overlay images', () => {
|
||||
renderThumbnail()
|
||||
const images = screen.getAllByRole('img')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0]).toHaveAttribute('src', '/base-image.jpg')
|
||||
expect(images[1]).toHaveAttribute('src', '/overlay-image.jpg')
|
||||
const wrapper = mountThumbnail()
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages.length).toBe(2)
|
||||
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
|
||||
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
|
||||
})
|
||||
|
||||
it('applies correct alt text to both images', () => {
|
||||
renderThumbnail({ alt: 'Custom Alt Text' })
|
||||
const images = screen.getAllByRole('img')
|
||||
expect(images[0]).toHaveAttribute('alt', 'Custom Alt Text')
|
||||
expect(images[1]).toHaveAttribute('alt', 'Custom Alt Text')
|
||||
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
|
||||
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
|
||||
})
|
||||
|
||||
it('applies clip-path style to overlay image', () => {
|
||||
renderThumbnail()
|
||||
const images = screen.getAllByRole('img')
|
||||
expect(images[1].style.clipPath).toContain('inset')
|
||||
const wrapper = mountThumbnail()
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageStyle = overlayLazyImage.props('imageStyle')
|
||||
expect(imageStyle.clipPath).toContain('inset')
|
||||
})
|
||||
|
||||
it('renders slider divider', () => {
|
||||
renderThumbnail()
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider).toBeDefined()
|
||||
const wrapper = mountThumbnail()
|
||||
const divider = wrapper.find('.bg-white\\/30')
|
||||
expect(divider.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('positions slider based on default value', () => {
|
||||
renderThumbnail()
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('50%')
|
||||
const wrapper = mountThumbnail()
|
||||
const divider = wrapper.find('.bg-white\\/30')
|
||||
expect(divider.attributes('style')).toContain('left: 50%')
|
||||
})
|
||||
|
||||
it('passes isHovered prop to BaseThumbnail', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: true })
|
||||
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
|
||||
expect(baseThumbnail.props('isHovered')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
data-testid="compare-slider-divider"
|
||||
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 bg-white/30 backdrop-blur-sm"
|
||||
:style="{
|
||||
left: `${sliderPosition}%`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
@@ -21,8 +21,8 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
}))
|
||||
|
||||
describe('HoverDissolveThumbnail', () => {
|
||||
const renderThumbnail = (props = {}) => {
|
||||
return render(HoverDissolveThumbnail, {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(HoverDissolveThumbnail, {
|
||||
props: {
|
||||
baseImageSrc: '/base-image.jpg',
|
||||
overlayImageSrc: '/overlay-image.jpg',
|
||||
@@ -35,31 +35,75 @@ describe('HoverDissolveThumbnail', () => {
|
||||
}
|
||||
|
||||
it('renders both base and overlay images', () => {
|
||||
renderThumbnail()
|
||||
const images = screen.getAllByRole('img')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0]).toHaveAttribute('src', '/base-image.jpg')
|
||||
expect(images[1]).toHaveAttribute('src', '/overlay-image.jpg')
|
||||
const wrapper = mountThumbnail()
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages.length).toBe(2)
|
||||
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
|
||||
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
|
||||
})
|
||||
|
||||
it('applies correct alt text to both images', () => {
|
||||
renderThumbnail({ alt: 'Custom Alt Text' })
|
||||
const images = screen.getAllByRole('img')
|
||||
expect(images[0]).toHaveAttribute('alt', 'Custom Alt Text')
|
||||
expect(images[1]).toHaveAttribute('alt', 'Custom Alt Text')
|
||||
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
|
||||
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
|
||||
})
|
||||
|
||||
it('makes overlay image visible when hovered', () => {
|
||||
renderThumbnail({ isHovered: true })
|
||||
const images = screen.getAllByRole('img')
|
||||
expect(images[1]).toHaveClass('opacity-100')
|
||||
expect(images[1]).not.toHaveClass('opacity-0')
|
||||
const wrapper = mountThumbnail({ isHovered: true })
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('opacity-100')
|
||||
expect(classString).not.toContain('opacity-0')
|
||||
})
|
||||
|
||||
it('makes overlay image hidden when not hovered', () => {
|
||||
renderThumbnail({ isHovered: false })
|
||||
const images = screen.getAllByRole('img')
|
||||
expect(images[1]).toHaveClass('opacity-0')
|
||||
expect(images[1]).not.toHaveClass('opacity-100')
|
||||
const wrapper = mountThumbnail({ isHovered: false })
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('opacity-0')
|
||||
expect(classString).not.toContain('opacity-100')
|
||||
})
|
||||
|
||||
it('passes isHovered prop to BaseThumbnail', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: true })
|
||||
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
|
||||
expect(baseThumbnail.props('isHovered')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies transition classes to overlay image', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('transition-opacity')
|
||||
expect(classString).toContain('duration-300')
|
||||
})
|
||||
|
||||
it('applies correct positioning to both images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
|
||||
// Check base image
|
||||
const baseImageClass = lazyImages[0].props('imageClass')
|
||||
const baseClassList = Array.isArray(baseImageClass)
|
||||
? baseImageClass
|
||||
: baseImageClass.split(' ')
|
||||
expect(baseClassList).toContain('size-full')
|
||||
|
||||
// Check overlay image
|
||||
const overlayImageClass = lazyImages[1].props('imageClass')
|
||||
const overlayClassList = Array.isArray(overlayImageClass)
|
||||
? overlayImageClass
|
||||
: overlayImageClass.split(' ')
|
||||
expect(overlayClassList).toContain('size-full')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -196,39 +196,32 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
mockAuthStoreState.isFetchingBalance = false
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const mountComponent = (): VueWrapper => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
const onClose = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(CurrentUserPopoverLegacy, {
|
||||
return mount(CurrentUserPopoverLegacy, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Divider: true
|
||||
}
|
||||
},
|
||||
props: {
|
||||
onClose
|
||||
}
|
||||
})
|
||||
|
||||
return { user, onClose }
|
||||
}
|
||||
|
||||
it('renders user information correctly', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(screen.getByText('Test User')).toBeInTheDocument()
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
||||
expect(wrapper.text()).toContain('Test User')
|
||||
expect(wrapper.text()).toContain('test@example.com')
|
||||
})
|
||||
|
||||
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
cents: 100_000,
|
||||
@@ -239,72 +232,103 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
// Verify the formatted credit string (1000) is rendered in the DOM
|
||||
expect(wrapper.text()).toContain('1000')
|
||||
})
|
||||
|
||||
it('renders logout menu item with correct text', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(screen.getByTestId('logout-menu-item')).toBeInTheDocument()
|
||||
expect(screen.getByText('Log Out')).toBeInTheDocument()
|
||||
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
|
||||
expect(logoutItem.exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Log Out')
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings item is clicked', async () => {
|
||||
const { user, onClose } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(screen.getByTestId('user-settings-menu-item')).toBeInTheDocument()
|
||||
const settingsItem = wrapper.find('[data-testid="user-settings-menu-item"]')
|
||||
expect(settingsItem.exists()).toBe(true)
|
||||
|
||||
await user.click(screen.getByTestId('user-settings-menu-item'))
|
||||
await settingsItem.trigger('click')
|
||||
|
||||
// Verify showSettingsDialog was called with 'user'
|
||||
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('calls logout function and emits close event when logout item is clicked', async () => {
|
||||
const { user, onClose } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(screen.getByTestId('logout-menu-item')).toBeInTheDocument()
|
||||
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
|
||||
expect(logoutItem.exists()).toBe(true)
|
||||
|
||||
await user.click(screen.getByTestId('logout-menu-item'))
|
||||
await logoutItem.trigger('click')
|
||||
|
||||
// Verify handleSignOut was called
|
||||
expect(mockHandleSignOut).toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens API pricing docs and emits close event when partner nodes item is clicked', async () => {
|
||||
const { user, onClose } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(screen.getByTestId('partner-nodes-menu-item')).toBeInTheDocument()
|
||||
const partnerNodesItem = wrapper.find(
|
||||
'[data-testid="partner-nodes-menu-item"]'
|
||||
)
|
||||
expect(partnerNodesItem.exists()).toBe(true)
|
||||
|
||||
await user.click(screen.getByTestId('partner-nodes-menu-item'))
|
||||
await partnerNodesItem.trigger('click')
|
||||
|
||||
// Verify window.open was called with the correct URL
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://docs.comfy.org/tutorials/partner-nodes/pricing',
|
||||
'_blank'
|
||||
)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
|
||||
const { user, onClose } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
|
||||
const topUpButton = wrapper.find('[data-testid="add-credits-button"]')
|
||||
expect(topUpButton.exists()).toBe(true)
|
||||
|
||||
await user.click(screen.getByTestId('add-credits-button'))
|
||||
await topUpButton.trigger('click')
|
||||
|
||||
// Verify showTopUpCreditsDialog was called
|
||||
expect(mockShowTopUpCreditsDialog).toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens subscription dialog and emits close event when plans & pricing item is clicked', async () => {
|
||||
const { user, onClose } = renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(screen.getByTestId('plans-pricing-menu-item')).toBeInTheDocument()
|
||||
const plansPricingItem = wrapper.find(
|
||||
'[data-testid="plans-pricing-menu-item"]'
|
||||
)
|
||||
expect(plansPricingItem.exists()).toBe(true)
|
||||
|
||||
await user.click(screen.getByTestId('plans-pricing-menu-item'))
|
||||
await plansPricingItem.trigger('click')
|
||||
|
||||
// Verify showPricingTable was called
|
||||
expect(mockShowPricingTable).toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
describe('effective_balance_micros handling', () => {
|
||||
@@ -315,7 +339,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
cents: 150_000,
|
||||
@@ -325,7 +349,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
expect(screen.getByText('1500')).toBeInTheDocument()
|
||||
expect(wrapper.text()).toContain('1500')
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when zero', () => {
|
||||
@@ -335,7 +359,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
cents: 0,
|
||||
@@ -345,7 +369,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(wrapper.text()).toContain('0')
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when negative', () => {
|
||||
@@ -355,7 +379,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
cents: -50_000,
|
||||
@@ -365,7 +389,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
expect(screen.getByText('-500')).toBeInTheDocument()
|
||||
expect(wrapper.text()).toContain('-500')
|
||||
})
|
||||
|
||||
it('falls back to amount_micros when effective_balance_micros is missing', () => {
|
||||
@@ -374,7 +398,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
cents: 100_000,
|
||||
@@ -384,7 +408,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
expect(wrapper.text()).toContain('1000')
|
||||
})
|
||||
|
||||
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
|
||||
@@ -392,7 +416,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
cents: 0,
|
||||
@@ -402,7 +426,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(wrapper.text()).toContain('0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -412,47 +436,53 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
})
|
||||
|
||||
it('hides credits section', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[data-testid="add-credits-button"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
screen.queryByTestId('upgrade-to-add-credits-button')
|
||||
).not.toBeInTheDocument()
|
||||
wrapper.find('[data-testid="upgrade-to-add-credits-button"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides subscribe button', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).not.toContain('Subscribe Button')
|
||||
})
|
||||
|
||||
it('hides partner nodes menu item', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
screen.queryByTestId('partner-nodes-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
wrapper.find('[data-testid="partner-nodes-menu-item"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides plans & pricing menu item', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
screen.queryByTestId('plans-pricing-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
wrapper.find('[data-testid="plans-pricing-menu-item"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides manage plan menu item', () => {
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
screen.queryByTestId('manage-plan-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
wrapper.find('[data-testid="manage-plan-menu-item"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('still shows user settings menu item', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('user-settings-menu-item')).toBeInTheDocument()
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
wrapper.find('[data-testid="user-settings-menu-item"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('still shows logout menu item', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('logout-menu-item')).toBeInTheDocument()
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[data-testid="logout-menu-item"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import TopbarSubscribeButton from './TopbarSubscribeButton.vue'
|
||||
@@ -47,14 +46,14 @@ vi.mock('firebase/auth', () => ({
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
function mountComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return render(TopbarSubscribeButton, {
|
||||
return mount(TopbarSubscribeButton, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
@@ -64,15 +63,17 @@ function renderComponent() {
|
||||
describe('TopbarSubscribeButton', () => {
|
||||
it('renders on cloud when isFreeTier is true', () => {
|
||||
mockIsCloud.value = true
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('topbar-subscribe-button')).toBeInTheDocument()
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
wrapper.find('[data-testid="topbar-subscribe-button"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides on non-cloud distribution', () => {
|
||||
mockIsCloud.value = false
|
||||
renderComponent()
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
screen.queryByTestId('topbar-subscribe-button')
|
||||
).not.toBeInTheDocument()
|
||||
wrapper.find('[data-testid="topbar-subscribe-button"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { nextTick, reactive, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
@@ -184,14 +184,14 @@ const createTask = (
|
||||
|
||||
const mountUseJobList = () => {
|
||||
let composable: ReturnType<typeof useJobList>
|
||||
const result = render({
|
||||
const wrapper = mount({
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useJobList()
|
||||
return {}
|
||||
}
|
||||
})
|
||||
return { ...result, composable: composable! }
|
||||
return { wrapper, composable: composable! }
|
||||
}
|
||||
|
||||
const resetStores = () => {
|
||||
@@ -230,27 +230,27 @@ const flush = async () => {
|
||||
}
|
||||
|
||||
describe('useJobList', () => {
|
||||
let unmount: (() => void) | null = null
|
||||
let wrapper: ReturnType<typeof mount> | null = null
|
||||
let api: ReturnType<typeof useJobList> | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
resetStores()
|
||||
unmount?.()
|
||||
unmount = null
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
api = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unmount?.()
|
||||
unmount = null
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
api = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const initComposable = () => {
|
||||
const mounted = mountUseJobList()
|
||||
unmount = mounted.unmount
|
||||
wrapper = mounted.wrapper
|
||||
api = mounted.composable
|
||||
return api!
|
||||
}
|
||||
@@ -321,8 +321,8 @@ describe('useJobList', () => {
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0)
|
||||
|
||||
unmount?.()
|
||||
unmount = null
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
@@ -45,14 +45,14 @@ vi.mock('@/stores/executionStore', () => {
|
||||
|
||||
const mountComposable = () => {
|
||||
let composable: ReturnType<typeof useQueueNotificationBanners>
|
||||
const result = render({
|
||||
const wrapper = mount({
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useQueueNotificationBanners()
|
||||
return {}
|
||||
}
|
||||
})
|
||||
return { ...result, composable: composable! }
|
||||
return { wrapper, composable: composable! }
|
||||
}
|
||||
|
||||
describe(useQueueNotificationBanners, () => {
|
||||
@@ -131,7 +131,7 @@ describe(useQueueNotificationBanners, () => {
|
||||
})
|
||||
|
||||
it('shows queued notifications from promptQueued events', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
mockApi.dispatchEvent(
|
||||
@@ -148,12 +148,12 @@ describe(useQueueNotificationBanners, () => {
|
||||
await nextTick()
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows queued pending then queued confirmation', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
mockApi.dispatchEvent(
|
||||
@@ -182,12 +182,12 @@ describe(useQueueNotificationBanners, () => {
|
||||
requestId: 1
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to 1 when queued batch count is invalid', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
mockApi.dispatchEvent(
|
||||
@@ -200,12 +200,12 @@ describe(useQueueNotificationBanners, () => {
|
||||
count: 1
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a completed notification from a finished batch', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
@@ -225,12 +225,12 @@ describe(useQueueNotificationBanners, () => {
|
||||
thumbnailUrls: ['https://example.com/preview.png']
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows one completion notification when history updates after queue becomes idle', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
vi.setSystemTime(4_000)
|
||||
@@ -266,12 +266,12 @@ describe(useQueueNotificationBanners, () => {
|
||||
await nextTick()
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('queues both completed and failed notifications for mixed batches', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
@@ -302,12 +302,12 @@ describe(useQueueNotificationBanners, () => {
|
||||
count: 1
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses up to two completion thumbnails for notification icon previews', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
@@ -342,7 +342,7 @@ describe(useQueueNotificationBanners, () => {
|
||||
]
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { formatPercent0 } from '@/utils/numberUtil'
|
||||
@@ -31,16 +32,19 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => createExecutionStoreMock()
|
||||
}))
|
||||
|
||||
const mountedWrappers: VueWrapper[] = []
|
||||
|
||||
const mountUseQueueProgress = () => {
|
||||
let composable: ReturnType<typeof useQueueProgress>
|
||||
render({
|
||||
const wrapper = mount({
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useQueueProgress()
|
||||
return {}
|
||||
}
|
||||
})
|
||||
return { composable: composable! }
|
||||
mountedWrappers.push(wrapper)
|
||||
return { wrapper, composable: composable! }
|
||||
}
|
||||
|
||||
const setExecutionProgress = (value?: number | null) => {
|
||||
@@ -58,6 +62,10 @@ describe('useQueueProgress', () => {
|
||||
setExecutingNodeProgress(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount())
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'defaults to 0% when execution store values are missing',
|
||||
|
||||
@@ -123,8 +123,7 @@ export const useContextMenuTranslation = () => {
|
||||
}
|
||||
|
||||
// for capture translation text of input and widget
|
||||
const extraInfo = (options.extra ||
|
||||
options.parentMenu?.options?.extra) as
|
||||
const extraInfo = (options.extra || options.parentMenu?.options?.extra) as
|
||||
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
|
||||
| undefined
|
||||
// widgets and inputs
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
}
|
||||
|
||||
function createMockNode(
|
||||
overrides: Partial<ComfyNodeDefImpl>
|
||||
): ComfyNodeDefImpl {
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface PromotedWidgetView extends IBaseWidget {
|
||||
* origin.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
/** Whether the resolved source widget is workflow-persistent. */
|
||||
readonly sourceSerialize: boolean
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
@@ -77,15 +77,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
/**
|
||||
* Whether the resolved source widget is workflow-persistent.
|
||||
* Used by SubgraphNode.serialize to skip preview/audio/video widgets
|
||||
* whose source sets serialize = false.
|
||||
*/
|
||||
get sourceSerialize(): boolean {
|
||||
return this.resolveDeepest()?.widget.serialize !== false
|
||||
}
|
||||
|
||||
last_y?: number
|
||||
computedHeight?: number
|
||||
|
||||
@@ -158,43 +149,13 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
private get _instanceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
const instanceValue = this.subgraphNode._instanceWidgetValues.get(
|
||||
this._instanceKey
|
||||
)
|
||||
if (instanceValue !== undefined)
|
||||
return instanceValue as IBaseWidget['value']
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution-time serialization — returns the per-instance value stored
|
||||
* during configure, falling back to the regular value getter.
|
||||
*
|
||||
* The widget state store is shared across instances (keyed by inner node
|
||||
* ID), so the regular getter returns the last-configured value for all
|
||||
* instances. graphToPrompt already prefers serializeValue over .value,
|
||||
* so this is the hook that makes multi-instance execution correct.
|
||||
*/
|
||||
serializeValue(): IBaseWidget['value'] {
|
||||
const v = this.subgraphNode._instanceWidgetValues.get(this._instanceKey)
|
||||
if (v !== undefined) return v as IBaseWidget['value']
|
||||
return this.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
// Keep per-instance map in sync for execution (graphToPrompt)
|
||||
this.subgraphNode._instanceWidgetValues.set(this._instanceKey, value)
|
||||
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('resolvePromotedWidgetAtHost', () => {
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(
|
||||
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize stores widgets_values for promoted views', () => {
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -265,7 +265,9 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.widgets_values).toEqual(['value'])
|
||||
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
|
||||
// Even if it were set, views have serialize: false and would be skipped.
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const TEST_CUSTOM_COMBO_TYPE = 'test/CustomComboCopyPaste'
|
||||
|
||||
class TestCustomComboNode extends LGraphNode {
|
||||
static override title = 'CustomCombo'
|
||||
|
||||
constructor() {
|
||||
super('CustomCombo')
|
||||
this.serialize_widgets = true
|
||||
this.addOutput('value', '*')
|
||||
this.addWidget('combo', 'value', '', () => {}, {
|
||||
values: [] as string[]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function findWidget(node: LGraphNode, name: string) {
|
||||
return node.widgets?.find((widget) => widget.name === name)
|
||||
}
|
||||
|
||||
function getCustomWidgetsExtension(): ComfyExtension {
|
||||
const extension = useExtensionStore().extensions.find(
|
||||
(candidate) => candidate.name === 'Comfy.CustomWidgets'
|
||||
)
|
||||
|
||||
if (!extension) {
|
||||
throw new Error('Comfy.CustomWidgets extension was not registered')
|
||||
}
|
||||
|
||||
return extension
|
||||
}
|
||||
|
||||
describe('CustomCombo copy/paste', () => {
|
||||
beforeAll(async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
await import('./customWidgets')
|
||||
|
||||
const extension = getCustomWidgetsExtension()
|
||||
await extension.beforeRegisterNodeDef?.(
|
||||
TestCustomComboNode,
|
||||
{ name: 'CustomCombo' } as ComfyNodeDef,
|
||||
app
|
||||
)
|
||||
|
||||
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
|
||||
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
|
||||
}
|
||||
LiteGraph.registerNodeType(TEST_CUSTOM_COMBO_TYPE, TestCustomComboNode)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
|
||||
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves combo options and selected value through clone and paste', () => {
|
||||
const graph = new LGraph()
|
||||
type AppWithRootGraph = { rootGraphInternal?: LGraph }
|
||||
const appWithRootGraph = app as unknown as AppWithRootGraph
|
||||
const previousRootGraph = appWithRootGraph.rootGraphInternal
|
||||
appWithRootGraph.rootGraphInternal = graph
|
||||
|
||||
try {
|
||||
const original = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
|
||||
graph.add(original)
|
||||
|
||||
findWidget(original, 'option1')!.value = 'alpha'
|
||||
findWidget(original, 'option2')!.value = 'beta'
|
||||
findWidget(original, 'option3')!.value = 'gamma'
|
||||
findWidget(original, 'value')!.value = 'beta'
|
||||
|
||||
const clonedSerialised = original.clone()?.serialize()
|
||||
|
||||
expect(clonedSerialised).toBeDefined()
|
||||
|
||||
const pasted = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
|
||||
pasted.configure(clonedSerialised!)
|
||||
graph.add(pasted)
|
||||
|
||||
expect(findWidget(pasted, 'value')!.value).toBe('beta')
|
||||
expect(findWidget(pasted, 'option1')!.value).toBe('alpha')
|
||||
expect(findWidget(pasted, 'option2')!.value).toBe('beta')
|
||||
expect(findWidget(pasted, 'option3')!.value).toBe('gamma')
|
||||
expect(findWidget(pasted, 'value')!.options.values).toEqual([
|
||||
'alpha',
|
||||
'beta',
|
||||
'gamma'
|
||||
])
|
||||
} finally {
|
||||
appWithRootGraph.rootGraphInternal = previousRootGraph
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,7 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
(w) => w.name.startsWith('option') && w.value
|
||||
).map((w) => `${w.value}`)
|
||||
)
|
||||
if (app.configuringGraph || !this.graph) return
|
||||
if (app.configuringGraph) return
|
||||
if (values.includes(`${comboWidget.value}`)) return
|
||||
comboWidget.value = values[0] ?? ''
|
||||
comboWidget.callback?.(comboWidget.value)
|
||||
@@ -71,9 +71,6 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
comboWidget.callback = useChainCallback(comboWidget.callback, () =>
|
||||
this.applyToGraph!()
|
||||
)
|
||||
this.onAdded = useChainCallback(this.onAdded, function () {
|
||||
updateCombo()
|
||||
})
|
||||
|
||||
function addOption(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
@@ -81,17 +78,16 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
const widgetName = `option${newCount}`
|
||||
const widget = node.addWidget('string', widgetName, '', () => {})
|
||||
if (!widget) return
|
||||
let localValue = `${widget.value ?? ''}`
|
||||
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return (
|
||||
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
|
||||
?.value ?? localValue
|
||||
)
|
||||
return useWidgetValueStore().getWidget(
|
||||
app.rootGraph.id,
|
||||
node.id,
|
||||
widgetName
|
||||
)?.value
|
||||
},
|
||||
set(v: string) {
|
||||
localValue = v
|
||||
const state = useWidgetValueStore().getWidget(
|
||||
app.rootGraph.id,
|
||||
node.id,
|
||||
|
||||
@@ -186,16 +186,11 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
if (!widget) return
|
||||
|
||||
// Special case: SubgraphNode widget.
|
||||
// Prefer serializeValue (per-instance) over the shared .value getter
|
||||
// so multiple SubgraphNode instances return their own configured values.
|
||||
const widgetValue = widget.serializeValue
|
||||
? widget.serializeValue(subgraphNode, -1)
|
||||
: widget.value
|
||||
return {
|
||||
node: this,
|
||||
origin_id: this.id,
|
||||
origin_slot: -1,
|
||||
widgetInfo: { value: widgetValue }
|
||||
widgetInfo: { value: widget.value }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
|
||||
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
function createNodeWithWidget(
|
||||
title: string,
|
||||
widgetValue: unknown = 42,
|
||||
slotType: ISlotType = 'number'
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
const input = node.addInput('value', slotType)
|
||||
node.addOutput('out', slotType)
|
||||
|
||||
// @ts-expect-error Abstract class instantiation
|
||||
const widget = new BaseWidget({
|
||||
name: 'widget',
|
||||
type: 'number',
|
||||
value: widgetValue,
|
||||
y: 0,
|
||||
options: { min: 0, max: 100, step: 1 },
|
||||
node
|
||||
})
|
||||
node.widgets = [widget]
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget, input }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
it('preserves per-instance widget values after configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
|
||||
|
||||
// Simulate what LGraph.configure does: call configure with different widgets_values
|
||||
instance1.configure({
|
||||
id: 201,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
instance2.configure({
|
||||
id: 202,
|
||||
type: subgraph.id,
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 1,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [20]
|
||||
})
|
||||
|
||||
const widgets1 = instance1.widgets!
|
||||
const widgets2 = instance2.widgets!
|
||||
|
||||
expect(widgets1.length).toBeGreaterThan(0)
|
||||
expect(widgets2.length).toBeGreaterThan(0)
|
||||
expect(widgets1[0].value).toBe(10)
|
||||
expect(widgets2[0].value).toBe(20)
|
||||
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
|
||||
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
|
||||
expect(instance1.serialize().widgets_values).toEqual([10])
|
||||
expect(instance2.serialize().widgets_values).toEqual([20])
|
||||
})
|
||||
|
||||
it('round-trips per-instance widget values through serialize and configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
|
||||
originalInstance.configure({
|
||||
id: 301,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 302,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
const restoredWidget = restoredInstance.widgets?.[0]
|
||||
expect(restoredWidget?.value).toBe(33)
|
||||
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
|
||||
})
|
||||
|
||||
it('skips non-serializable source widgets during serialize', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 10)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
// Mark the source widget as non-persistent (e.g. preview widget)
|
||||
widget.serialize = false
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 501 })
|
||||
instance.configure({
|
||||
id: 501,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: []
|
||||
})
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -993,20 +993,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
|
||||
private _pendingWidgetsValues?: unknown[]
|
||||
|
||||
/**
|
||||
* Per-instance promoted widget values.
|
||||
* Multiple SubgraphNode instances share the same inner nodes, so
|
||||
* promoted widget values must be stored per-instance to avoid collisions.
|
||||
* Key: `${sourceNodeId}:${sourceWidgetName}`
|
||||
*/
|
||||
readonly _instanceWidgetValues = new Map<string, unknown>()
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
this._pendingWidgetsValues = info.widgets_values
|
||||
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
input._listenerController &&
|
||||
@@ -1137,21 +1124,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
|
||||
store.promote(this.rootGraph.id, this.id, source)
|
||||
}
|
||||
|
||||
// Hydrate per-instance promoted widget values from serialized data.
|
||||
// LGraphNode.configure skips promoted widgets (serialize === false on
|
||||
// the view), so they must be applied here after promoted views exist.
|
||||
// Only iterate serializable views to match what serialize() wrote.
|
||||
if (this._pendingWidgetsValues) {
|
||||
const views = this._getPromotedViews()
|
||||
let i = 0
|
||||
for (const view of views) {
|
||||
if (!view.sourceSerialize) continue
|
||||
if (i >= this._pendingWidgetsValues.length) break
|
||||
view.value = this._pendingWidgetsValues[i++] as typeof view.value
|
||||
}
|
||||
this._pendingWidgetsValues = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1601,7 +1573,28 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes widget values from this SubgraphNode instance to the
|
||||
* corresponding widgets in the subgraph definition before serialization.
|
||||
* This ensures nested subgraph widget values are preserved when saving.
|
||||
*/
|
||||
override serialize(): ISerialisedNode {
|
||||
// Sync widget values to subgraph definition before serialization.
|
||||
// Only sync for inputs that are linked to a promoted widget via _widget.
|
||||
for (const input of this.inputs) {
|
||||
if (!input._widget) continue
|
||||
|
||||
const subgraphInput =
|
||||
input._subgraphSlot ??
|
||||
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
for (const connectedWidget of connectedWidgets) {
|
||||
connectedWidget.value = input._widget.value
|
||||
}
|
||||
}
|
||||
|
||||
// Write promotion store state back to properties for serialization
|
||||
const entries = usePromotionStore().getPromotions(
|
||||
this.rootGraph.id,
|
||||
@@ -1609,22 +1602,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
|
||||
const serialized = super.serialize()
|
||||
const views = this._getPromotedViews()
|
||||
|
||||
const serializableViews = views.filter((view) => view.sourceSerialize)
|
||||
if (serializableViews.length > 0) {
|
||||
serialized.widgets_values = serializableViews.map((view) => {
|
||||
const value = view.serializeValue
|
||||
? view.serializeValue(this, -1)
|
||||
: view.value
|
||||
return value != null && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: (value ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
return serialized
|
||||
return super.serialize()
|
||||
}
|
||||
override clone() {
|
||||
const clone = super.clone()
|
||||
|
||||
@@ -1331,7 +1331,7 @@
|
||||
"updating": "Actualizando",
|
||||
"upload": "Subir",
|
||||
"uploadAlreadyInProgress": "La carga ya está en curso",
|
||||
"uploadTimedOut": "La carga ha excedido el tiempo de espera. Por favor, inténtalo de nuevo.",
|
||||
"uploadTimedOut": "La carga ha excedido el tiempo límite. Por favor, inténtalo de nuevo.",
|
||||
"usageHint": "Sugerencia de uso",
|
||||
"use": "Usar",
|
||||
"user": "Usuario",
|
||||
|
||||
@@ -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 { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -62,16 +61,13 @@ const createJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
...overrides
|
||||
})
|
||||
|
||||
function renderComponent(job: JobListItem) {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(ActiveJobCard, {
|
||||
const mountComponent = (job: JobListItem) =>
|
||||
mount(ActiveJobCard, {
|
||||
props: { job },
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
return { container, user }
|
||||
}
|
||||
|
||||
describe('ActiveJobCard', () => {
|
||||
beforeEach(() => {
|
||||
@@ -82,19 +78,18 @@ describe('ActiveJobCard', () => {
|
||||
})
|
||||
|
||||
it('displays percentage and progress bar when job is running', () => {
|
||||
const { container } = renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'running', progressTotalPercent: 65 })
|
||||
)
|
||||
|
||||
expect(container.textContent).toContain('65%')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- progress bar has no ARIA role in happy-dom
|
||||
const progressBar = container.querySelector('.bg-blue-500')
|
||||
expect(progressBar).not.toBeNull()
|
||||
expect(progressBar).toHaveStyle({ width: '65%' })
|
||||
expect(wrapper.text()).toContain('65%')
|
||||
const progressBar = wrapper.find('.bg-blue-500')
|
||||
expect(progressBar.exists()).toBe(true)
|
||||
expect(progressBar.attributes('style')).toContain('width: 65%')
|
||||
})
|
||||
|
||||
it('displays status text when job is pending', () => {
|
||||
const { container } = renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createJob({
|
||||
state: 'pending',
|
||||
title: 'In queue...',
|
||||
@@ -102,114 +97,116 @@ describe('ActiveJobCard', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(container.textContent).toContain('In queue...')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- progress bar has no ARIA role in happy-dom
|
||||
expect(container.querySelector('.bg-blue-500')).toBeNull()
|
||||
expect(wrapper.text()).toContain('In queue...')
|
||||
const progressBar = wrapper.find('.bg-blue-500')
|
||||
expect(progressBar.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows spinner for pending state', () => {
|
||||
const { container } = renderComponent(createJob({ state: 'pending' }))
|
||||
const wrapper = mountComponent(createJob({ state: 'pending' }))
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- spinner icon has no ARIA role in happy-dom
|
||||
const spinner = container.querySelector('[class*="lucide--loader-circle"]')
|
||||
expect(spinner).not.toBeNull()
|
||||
expect(spinner).toHaveClass('animate-spin')
|
||||
const spinner = wrapper.find('.icon-\\[lucide--loader-circle\\]')
|
||||
expect(spinner.exists()).toBe(true)
|
||||
expect(spinner.classes()).toContain('animate-spin')
|
||||
})
|
||||
|
||||
it('shows error icon for failed state', () => {
|
||||
const { container } = renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'failed', title: 'Failed' })
|
||||
)
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- error icon has no ARIA role in happy-dom
|
||||
const errorIcon = container.querySelector('[class*="lucide--circle-alert"]')
|
||||
expect(errorIcon).not.toBeNull()
|
||||
expect(container.textContent).toContain('Failed')
|
||||
const errorIcon = wrapper.find('.icon-\\[lucide--circle-alert\\]')
|
||||
expect(errorIcon.exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Failed')
|
||||
})
|
||||
|
||||
it('shows preview image when running with iconImageUrl', () => {
|
||||
renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createJob({
|
||||
state: 'running',
|
||||
iconImageUrl: 'https://example.com/preview.jpg'
|
||||
})
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/preview.jpg')
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('https://example.com/preview.jpg')
|
||||
})
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
renderComponent(createJob({ title: 'Generating...' }))
|
||||
const wrapper = mountComponent(createJob({ title: 'Generating...' }))
|
||||
|
||||
const status = screen.getByRole('status', {
|
||||
name: 'Active job: Generating...'
|
||||
})
|
||||
expect(status).toBeInTheDocument()
|
||||
const container = wrapper.find('[role="status"]')
|
||||
expect(container.exists()).toBe(true)
|
||||
expect(container.attributes('aria-label')).toBe('Active job: Generating...')
|
||||
})
|
||||
|
||||
it('shows delete button on hover for failed jobs', async () => {
|
||||
mockCanDeleteJob.value = true
|
||||
|
||||
const { user } = renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'failed', title: 'Failed' })
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Remove job' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(wrapper.findComponent({ name: 'Button' }).exists()).toBe(false)
|
||||
|
||||
await user.hover(screen.getByRole('status'))
|
||||
await wrapper.find('[role="status"]').trigger('mouseenter')
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Remove job' })
|
||||
).toBeInTheDocument()
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.attributes('aria-label')).toBe('Remove job')
|
||||
})
|
||||
|
||||
it('calls runDeleteJob when delete button is clicked on a failed job', async () => {
|
||||
mockCanDeleteJob.value = true
|
||||
|
||||
const { user } = renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'failed', title: 'Failed' })
|
||||
)
|
||||
|
||||
await user.hover(screen.getByRole('status'))
|
||||
await user.click(screen.getByRole('button', { name: 'Remove job' }))
|
||||
await wrapper.find('[role="status"]').trigger('mouseenter')
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
await button.trigger('click')
|
||||
|
||||
expect(mockRunDeleteJob).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not show action button when job cannot be cancelled or deleted', async () => {
|
||||
const { user } = renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'running', progressTotalPercent: 50 })
|
||||
)
|
||||
|
||||
await user.hover(screen.getByRole('status', { name: /Active job/ }))
|
||||
await wrapper.find('[role="status"]').trigger('mouseenter')
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(wrapper.findComponent({ name: 'Button' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows cancel button on hover for cancellable jobs', async () => {
|
||||
mockCanCancelJob.value = true
|
||||
|
||||
const { user } = renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'running', progressTotalPercent: 50 })
|
||||
)
|
||||
|
||||
await user.hover(screen.getByRole('status', { name: /Active job/ }))
|
||||
await wrapper.find('[role="status"]').trigger('mouseenter')
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.attributes('aria-label')).toBe('Cancel')
|
||||
})
|
||||
|
||||
it('calls runCancelJob when cancel button is clicked', async () => {
|
||||
mockCanCancelJob.value = true
|
||||
|
||||
const { user } = renderComponent(
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'running', progressTotalPercent: 50 })
|
||||
)
|
||||
|
||||
await user.hover(screen.getByRole('status', { name: /Active job/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await wrapper.find('[role="status"]').trigger('mouseenter')
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
await button.trigger('click')
|
||||
|
||||
expect(mockRunCancelJob).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -61,7 +60,6 @@ vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
|
||||
emits: ['close'],
|
||||
template: `
|
||||
<div data-testid="base-modal-layout">
|
||||
<span data-testid="modal-title">{{ contentTitle }}</span>
|
||||
<div v-if="$slots.leftPanel" data-testid="left-panel">
|
||||
<slot name="leftPanel" />
|
||||
</div>
|
||||
@@ -89,27 +87,15 @@ vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
|
||||
<div v-if="$slots['header-title']" data-testid="header-title">
|
||||
<slot name="header-title" />
|
||||
</div>
|
||||
<template v-for="item in navItems" :key="item.id || item.title">
|
||||
<button
|
||||
v-if="item.id"
|
||||
@click="$emit('update:modelValue', item.id)"
|
||||
:data-testid="'nav-item-' + item.id"
|
||||
:class="{ active: modelValue === item.id }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<template v-else-if="item.items">
|
||||
<button
|
||||
v-for="child in item.items"
|
||||
:key="child.id"
|
||||
@click="$emit('update:modelValue', child.id)"
|
||||
:data-testid="'nav-item-' + child.id"
|
||||
:class="{ active: modelValue === child.id }"
|
||||
>
|
||||
{{ child.label }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
@click="$emit('update:modelValue', item.id)"
|
||||
:data-testid="'nav-item-' + item.id"
|
||||
:class="{ active: modelValue === item.id }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -165,9 +151,6 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const flushPromises = () =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
describe('AssetBrowserModal', () => {
|
||||
const createTestAsset = (
|
||||
id: string,
|
||||
@@ -190,11 +173,11 @@ describe('AssetBrowserModal', () => {
|
||||
}
|
||||
})
|
||||
|
||||
function renderModal(props: Record<string, unknown>) {
|
||||
const createWrapper = (props: Record<string, unknown>) => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
return render(AssetBrowserModal, {
|
||||
return mount(AssetBrowserModal, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
@@ -224,16 +207,14 @@ describe('AssetBrowserModal', () => {
|
||||
]
|
||||
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
renderModal({ nodeType: 'CheckpointLoaderSimple' })
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByTestId('asset-asset1')).toBeDefined()
|
||||
expect(screen.getByTestId('asset-asset2')).toBeDefined()
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
expect(
|
||||
screen.getByTestId('asset-grid').querySelectorAll('.asset-card')
|
||||
).toHaveLength(2)
|
||||
/* eslint-enable testing-library/no-node-access */
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
const gridAssets = assetGrid.props('assets') as AssetItem[]
|
||||
|
||||
expect(gridAssets).toHaveLength(2)
|
||||
expect(gridAssets[0].id).toBe('asset1')
|
||||
})
|
||||
|
||||
it('passes category-filtered assets to AssetFilterBar', async () => {
|
||||
@@ -243,22 +224,23 @@ describe('AssetBrowserModal', () => {
|
||||
]
|
||||
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
renderModal({
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
showLeftPanel: true
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByTestId('asset-filter-bar').textContent).toContain(
|
||||
'2 assets'
|
||||
)
|
||||
const filterBar = wrapper.findComponent({ name: 'AssetFilterBar' })
|
||||
const filterBarAssets = filterBar.props('assets') as AssetItem[]
|
||||
|
||||
expect(filterBarAssets).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data fetching', () => {
|
||||
it('triggers store refresh for node type on mount', async () => {
|
||||
const store = useAssetsStore()
|
||||
renderModal({ nodeType: 'CheckpointLoaderSimple' })
|
||||
createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
expect(store.updateModelsForNodeType).toHaveBeenCalledWith(
|
||||
@@ -270,17 +252,18 @@ describe('AssetBrowserModal', () => {
|
||||
const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')]
|
||||
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
renderModal({ nodeType: 'CheckpointLoaderSimple' })
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
|
||||
expect(screen.getByTestId('asset-asset1')).toBeDefined()
|
||||
expect(screen.getByTestId('asset-asset1').textContent).toContain(
|
||||
'Cached Model'
|
||||
)
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
const gridAssets = assetGrid.props('assets') as AssetItem[]
|
||||
|
||||
expect(gridAssets).toHaveLength(1)
|
||||
expect(gridAssets[0].name).toBe('Cached Model')
|
||||
})
|
||||
|
||||
it('triggers store refresh for asset type (tag) on mount', async () => {
|
||||
const store = useAssetsStore()
|
||||
renderModal({ assetType: 'models' })
|
||||
createWrapper({ assetType: 'models' })
|
||||
await flushPromises()
|
||||
|
||||
expect(store.updateModelsForTag).toHaveBeenCalledWith('models')
|
||||
@@ -290,133 +273,116 @@ describe('AssetBrowserModal', () => {
|
||||
const assets = [createTestAsset('asset1', 'Tagged Model', 'models')]
|
||||
mockAssetsByKey.set('tag:models', assets)
|
||||
|
||||
renderModal({ assetType: 'models' })
|
||||
const wrapper = createWrapper({ assetType: 'models' })
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByTestId('asset-asset1')).toBeDefined()
|
||||
expect(screen.getByTestId('asset-asset1').textContent).toContain(
|
||||
'Tagged Model'
|
||||
)
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
const gridAssets = assetGrid.props('assets') as AssetItem[]
|
||||
|
||||
expect(gridAssets).toHaveLength(1)
|
||||
expect(gridAssets[0].name).toBe('Tagged Model')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Asset Selection', () => {
|
||||
it('emits asset-select event when asset is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
|
||||
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
const onAssetSelect = vi.fn()
|
||||
renderModal({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
'onAsset-select': onAssetSelect
|
||||
})
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
await user.click(screen.getByTestId('asset-asset1'))
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
await assetGrid.vm.$emit('asset-select', assets[0])
|
||||
|
||||
expect(onAssetSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: assets[0].id, name: assets[0].name })
|
||||
)
|
||||
expect(wrapper.emitted('asset-select')).toEqual([[assets[0]]])
|
||||
})
|
||||
|
||||
it('executes onSelect callback when provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
|
||||
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
const onSelect = vi.fn()
|
||||
renderModal({
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
onSelect
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await user.click(screen.getByTestId('asset-asset1'))
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
await assetGrid.vm.$emit('asset-select', assets[0])
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: assets[0].id, name: assets[0].name })
|
||||
)
|
||||
expect(onSelect).toHaveBeenCalledWith(assets[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Left Panel Conditional Logic', () => {
|
||||
it('hides left panel by default when showLeftPanel is undefined', async () => {
|
||||
renderModal({ nodeType: 'CheckpointLoaderSimple' })
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.queryByTestId('left-panel')).toBeNull()
|
||||
const leftPanel = wrapper.find('[data-testid="left-panel"]')
|
||||
expect(leftPanel.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows left panel when showLeftPanel prop is explicitly true', async () => {
|
||||
renderModal({
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
showLeftPanel: true
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByTestId('left-panel')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides left panel when showLeftPanel is false', async () => {
|
||||
renderModal({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
showLeftPanel: false
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.queryByTestId('left-panel')).toBeNull()
|
||||
const leftPanel = wrapper.find('[data-testid="left-panel"]')
|
||||
expect(leftPanel.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filter Options Reactivity', () => {
|
||||
it('updates filter options when category changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const assets = [
|
||||
createTestAsset('asset1', 'Model A', 'checkpoints'),
|
||||
createTestAsset('asset2', 'Model B', 'loras')
|
||||
]
|
||||
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
renderModal({
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
showLeftPanel: true
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByTestId('asset-filter-bar').textContent).toContain(
|
||||
'2 assets'
|
||||
)
|
||||
const filterBar = wrapper.findComponent({ name: 'AssetFilterBar' })
|
||||
expect(filterBar.props('assets')).toHaveLength(2)
|
||||
|
||||
await user.click(screen.getByTestId('nav-item-loras'))
|
||||
const leftPanel = wrapper.findComponent({ name: 'LeftSidePanel' })
|
||||
await leftPanel.vm.$emit('update:modelValue', 'loras')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('asset-filter-bar').textContent).toContain(
|
||||
'1 assets'
|
||||
)
|
||||
})
|
||||
expect(filterBar.props('assets')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Title Management', () => {
|
||||
it('passes custom title to BaseModalLayout when title prop provided', async () => {
|
||||
renderModal({
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
title: 'Custom Title'
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByTestId('modal-title').textContent).toBe('Custom Title')
|
||||
const layout = wrapper.findComponent({ name: 'BaseModalLayout' })
|
||||
expect(layout.props('contentTitle')).toBe('Custom Title')
|
||||
})
|
||||
|
||||
it('passes computed contentTitle to BaseModalLayout when no title prop', async () => {
|
||||
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
|
||||
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
renderModal({ nodeType: 'CheckpointLoaderSimple' })
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByTestId('modal-title').textContent).toBe(
|
||||
const layout = wrapper.findComponent({ name: 'BaseModalLayout' })
|
||||
expect(layout.props('contentTitle')).toBe(
|
||||
'assetBrowser.allCategory:{"category":"Checkpoints"}'
|
||||
)
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user