Compare commits

..

9 Commits

Author SHA1 Message Date
pythongosssss
d6509722c6 Ensure all save/save as buttons are the same width 2026-03-28 12:05:36 -07:00
pythongosssss
9e4e1d8f4b rabbit + fix 2026-03-28 11:42:26 -07:00
pythongosssss
566ad19866 improve 2026-03-28 10:44:50 -07:00
pythongosssss
66675dc27e add save as tests
further simplify/share between tests
2026-03-28 10:44:50 -07:00
pythongosssss
a4953083b6 refactor save tests to remove getByX and use fixtures 2026-03-28 10:44:50 -07:00
pythongosssss
58c6b68fcc refactor to component specific fixtures 2026-03-28 10:44:50 -07:00
pythongosssss
fe525fb879 guard graph extra 2026-03-28 10:39:24 -07:00
pythongosssss
84a5c8a404 fix test 2026-03-28 08:55:27 -07:00
pythongosssss
4ef2ce96bc fix save as not using correct extension or persisting mode on change 2026-03-28 07:10:22 -07:00
105 changed files with 1216 additions and 10821 deletions

View File

@@ -1,84 +0,0 @@
---
name: adding-deprecation-warnings
description: 'Adds deprecation warnings for renamed or removed properties/APIs. Searches custom node ecosystem for usage, applies defineDeprecatedProperty helper, adds JSDoc. Triggers on: deprecate, deprecation warning, rename property, backward compatibility.'
---
# Adding Deprecation Warnings
Adds backward-compatible deprecation warnings for renamed or removed
properties using the `defineDeprecatedProperty` helper in
`src/lib/litegraph/src/utils/feedback.ts`.
## When to Use
- A property or API has been renamed and custom nodes still use the old name
- A property is being removed but needs a grace period
- Backward compatibility must be preserved while nudging adoption
## Steps
### 1. Search the Custom Node Ecosystem
Before implementing, assess impact by searching for usage of the
deprecated property across ComfyUI custom nodes:
```text
Use the comfy_codesearch tool to search for the old property name.
Search for both `widget.oldProp` and just `oldProp` to catch all patterns.
```
Document the usage patterns found (property access, truthiness checks,
caching to local vars, style mutation, etc.) — these all must continue
working.
### 2. Apply the Deprecation
Use `defineDeprecatedProperty` from `src/lib/litegraph/src/utils/feedback.ts`:
```typescript
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
/** @deprecated Use {@link obj.newProp} instead. */
defineDeprecatedProperty(
obj,
'oldProp',
'newProp',
'obj.oldProp is deprecated. Use obj.newProp instead.'
)
```
### 3. Checklist
- [ ] Ecosystem search completed — all usage patterns are compatible
- [ ] `defineDeprecatedProperty` call added after the new property is assigned
- [ ] JSDoc `@deprecated` tag added above the call for IDE support
- [ ] Warning message names both old and new property clearly
- [ ] `pnpm typecheck` passes
- [ ] `pnpm lint` passes
### 4. PR Comment
Add a PR comment summarizing the ecosystem search results: which repos
use the deprecated property, what access patterns were found, and
confirmation that all patterns are compatible with the ODP getter/setter.
## How `defineDeprecatedProperty` Works
- Creates an `Object.defineProperty` getter/setter on the target object
- Getter returns `this[currentKey]`, setter assigns `this[currentKey]`
- Both log via `warnDeprecated`, which deduplicates (once per unique
message per session via a `Set`)
- `enumerable: false` keeps the alias out of `Object.keys()` / `for...in`
/ `JSON.stringify`
- `configurable: true` allows further redefinition if needed
## Edge Cases
- **Truthiness checks** (`if (widget.oldProp)`) — works, getter fires
- **Caching to local var** (`const el = widget.oldProp`) — works, warns
once then the cached ref is used directly
- **Style/property mutation** (`widget.oldProp.style.color = 'red'`) —
works, getter returns the real object
- **Serialization** (`JSON.stringify`) — `enumerable: false` excludes it
- **Heavy access in loops** — `warnDeprecated` deduplicates, only warns
once per session regardless of call count

View File

@@ -13,6 +13,8 @@ runs:
# Install pnpm, Node.js, build frontend
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -17,6 +17,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -22,6 +22,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -21,6 +21,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -20,6 +20,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0

View File

@@ -21,6 +21,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -74,6 +76,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0

View File

@@ -30,6 +30,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -85,6 +85,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -76,6 +76,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -201,6 +203,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- uses: actions/setup-node@v6
with:

View File

@@ -20,10 +20,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

@@ -76,6 +76,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

@@ -144,6 +144,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -52,6 +52,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -30,6 +30,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0 12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055 20.03 20.03 0 0 0 5.993 2.98.078.078 0 0 0 .084-.026c.462-.62.874-1.275 1.226-1.963.021-.04.001-.088-.041-.104a13.201 13.201 0 0 1-1.872-.878.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-2.981.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028ZM8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38 0-1.312.956-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.956 2.38-2.157 2.38Zm7.975 0c-1.183 0-2.157-1.069-2.157-2.38 0-1.312.955-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.946 2.38-2.157 2.38Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z"/></svg>

Before

Width:  |  Height:  |  Size: 819 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069ZM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881Z"/></svg>

Before

Width:  |  Height:  |  Size: 988 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286ZM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065Zm1.782 13.019H3.555V9h3.564v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z"/></svg>

Before

Width:  |  Height:  |  Size: 536 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm5.8 11.33c.02.16.03.33.03.5 0 2.55-2.97 4.63-6.63 4.63-3.65 0-6.62-2.07-6.62-4.63 0-.17.01-.34.03-.5a1.58 1.58 0 0 1-.63-1.27c0-.88.72-1.59 1.6-1.59.44 0 .83.18 1.12.46 1.1-.79 2.62-1.3 4.31-1.37l.73-3.44a.32.32 0 0 1 .39-.24l2.43.52a1.13 1.13 0 0 1 2.15.36 1.13 1.13 0 0 1-1.13 1.12 1.13 1.13 0 0 1-1.08-.82l-2.16-.46-.65 3.07c1.65.09 3.14.59 4.22 1.36.29-.28.69-.46 1.13-.46.88 0 1.6.71 1.6 1.59 0 .52-.25.97-.63 1.27ZM9.5 13.5c0 .63.51 1.13 1.13 1.13s1.12-.5 1.12-1.13-.5-1.12-1.12-1.12-1.13.5-1.13 1.12Zm5.75 2.55c-.69.69-2 .73-3.25.73s-2.56-.04-3.25-.73a.32.32 0 1 1 .45-.45c.44.44 1.37.6 2.8.6 1.43 0 2.37-.16 2.8-.6a.32.32 0 1 1 .45.45Zm-.37-1.42c.62 0 1.13-.5 1.13-1.13 0-.62-.51-1.12-1.13-1.12-.63 0-1.13.5-1.13 1.12 0 .63.5 1.13 1.13 1.13Z"/></svg>

Before

Width:  |  Height:  |  Size: 915 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>

Before

Width:  |  Height:  |  Size: 254 B

View File

@@ -1,143 +0,0 @@
<script setup lang="ts">
const columns = [
{
title: 'Product',
links: [
{ 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: 'Resources',
links: [
{ 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: 'Company',
links: [
{ label: 'About', href: '/about' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' }
]
},
{
title: 'Legal',
links: [
{ label: 'Terms of Service', href: '/terms-of-service' },
{ label: 'Privacy Policy', href: '/privacy-policy' }
]
}
]
const socials = [
{
label: 'GitHub',
href: 'https://github.com/comfyanonymous/ComfyUI',
icon: '/icons/social/github.svg'
},
{
label: 'Discord',
href: 'https://discord.gg/comfyorg',
icon: '/icons/social/discord.svg'
},
{
label: 'X',
href: 'https://x.com/comaboratory',
icon: '/icons/social/x.svg'
},
{
label: 'Reddit',
href: 'https://reddit.com/r/comfyui',
icon: '/icons/social/reddit.svg'
},
{
label: 'LinkedIn',
href: 'https://linkedin.com/company/comfyorg',
icon: '/icons/social/linkedin.svg'
},
{
label: 'Instagram',
href: 'https://instagram.com/comfyorg',
icon: '/icons/social/instagram.svg'
}
]
</script>
<template>
<footer class="border-t border-white/10 bg-black">
<div
class="mx-auto grid max-w-7xl gap-8 px-6 py-16 sm:grid-cols-2 lg:grid-cols-5"
>
<!-- Brand -->
<div class="lg:col-span-1">
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
Comfy
</a>
<p class="mt-4 text-sm text-smoke-700">
Professional control of visual AI.
</p>
</div>
<!-- Link columns -->
<nav
v-for="column in columns"
:key="column.title"
:aria-label="column.title"
class="flex flex-col gap-3"
>
<h3 class="text-sm font-semibold text-white">{{ column.title }}</h3>
<a
v-for="link in column.links"
:key="link.href"
:href="link.href"
class="text-sm text-smoke-700 transition-colors hover:text-white"
>
{{ link.label }}
</a>
</nav>
</div>
<!-- Bottom bar -->
<div class="border-t border-white/10">
<div
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
>
<p class="text-sm text-smoke-700">
&copy; {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
</p>
<!-- Social icons -->
<div class="flex items-center gap-4">
<a
v-for="social in socials"
:key="social.label"
:href="social.href"
:aria-label="social.label"
target="_blank"
rel="noopener noreferrer"
class="text-smoke-700 transition-colors hover:text-white"
>
<span
class="inline-block size-5 bg-current"
:style="{
maskImage: `url(${social.icon})`,
maskSize: 'contain',
maskRepeat: 'no-repeat',
WebkitMaskImage: `url(${social.icon})`,
WebkitMaskSize: 'contain',
WebkitMaskRepeat: 'no-repeat'
}"
aria-hidden="true"
/>
</a>
</div>
</div>
</div>
</footer>
</template>

View File

@@ -1,149 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const mobileMenuOpen = ref(false)
const currentPath = ref('')
const navLinks = [
{ label: 'ENTERPRISE', href: '/enterprise' },
{ label: 'GALLERY', href: '/gallery' },
{ label: 'ABOUT', href: '/about' },
{ label: 'CAREERS', href: '/careers' }
]
const ctaLinks = [
{
label: 'COMFY CLOUD',
href: 'https://app.comfy.org',
primary: true
},
{
label: 'COMFY HUB',
href: 'https://hub.comfy.org',
primary: false
}
]
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && mobileMenuOpen.value) {
mobileMenuOpen.value = false
}
}
function onAfterSwap() {
mobileMenuOpen.value = false
currentPath.value = window.location.pathname
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
document.addEventListener('astro:after-swap', onAfterSwap)
currentPath.value = window.location.pathname
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
document.removeEventListener('astro:after-swap', onAfterSwap)
})
</script>
<template>
<nav
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 -->
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
Comfy
</a>
<!-- Desktop nav links -->
<div class="hidden items-center gap-8 md:flex">
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
:aria-current="currentPath === link.href ? 'page' : undefined"
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
>
{{ link.label }}
</a>
<div class="flex items-center gap-3">
<a
v-for="cta in ctaLinks"
:key="cta.href"
:href="cta.href"
:class="
cta.primary
? '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"
>
{{ cta.label }}
</a>
</div>
</div>
<!-- Mobile hamburger -->
<button
class="flex flex-col gap-1.5 md:hidden"
aria-label="Toggle menu"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<span
class="block h-0.5 w-6 bg-white transition-transform"
:class="mobileMenuOpen && 'translate-y-2 rotate-45'"
/>
<span
class="block h-0.5 w-6 bg-white transition-opacity"
:class="mobileMenuOpen && 'opacity-0'"
/>
<span
class="block h-0.5 w-6 bg-white transition-transform"
:class="mobileMenuOpen && '-translate-y-2 -rotate-45'"
/>
</button>
</div>
<!-- Mobile menu -->
<div
v-show="mobileMenuOpen"
id="site-mobile-menu"
class="border-t border-white/10 bg-black px-6 pb-6 md:hidden"
>
<div class="flex flex-col gap-4 pt-4">
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
:aria-current="currentPath === link.href ? 'page' : undefined"
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
@click="mobileMenuOpen = false"
>
{{ link.label }}
</a>
<div class="flex flex-col gap-3 pt-2">
<a
v-for="cta in ctaLinks"
:key="cta.href"
:href="cta.href"
:class="
cta.primary
? '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"
>
{{ cta.label }}
</a>
</div>
</div>
</div>
</nav>
</template>

View File

@@ -1,86 +0,0 @@
---
import { ClientRouter } from 'astro:transitions'
import Analytics from '@vercel/analytics/astro'
import '../styles/global.css'
interface Props {
title: string
description?: string
ogImage?: string
}
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
ogImage = '/og-default.png',
} = Astro.props
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const locale = Astro.currentLocale ?? 'en'
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
---
<!doctype html>
<html lang={locale}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalURL.href} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImageURL.href} />
<meta property="og:url" content={canonicalURL.href} />
<meta property="og:locale" content={locale} />
<meta property="og:site_name" content="Comfy" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Google Tag Manager -->
{gtmEnabled && (
<script is:inline define:vars={{ gtmId }}>
;(function (w, d, s, l, i) {
w[l] = w[l] || []
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' })
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : ''
j.async = true
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', gtmId)
</script>
)}
<ClientRouter />
</head>
<body class="bg-black text-white font-inter antialiased">
{gtmEnabled && (
<noscript>
<iframe
src={`https://www.googletagmanager.com/ns.html?id=${gtmId}`}
height="0"
width="0"
style="display:none;visibility:hidden"
></iframe>
</noscript>
)}
<slot />
<Analytics />
</body>
</html>

View File

@@ -5,5 +5,5 @@
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "astro.config.ts"]
"include": ["src/**/*", "astro.config.mjs"]
}

View File

@@ -30,24 +30,6 @@ browser_tests/
└── tests/ - Test files (*.spec.ts)
```
## Polling Assertions
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
```typescript
// ✅ Correct — single async call + single assertion
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 250 })
.toBe(0)
// ❌ Avoid — nested expect inside toPass
await expect(async () => {
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 250 })
```
Reserve `toPass()` for blocks with multiple assertions or complex async logic that can't be expressed as a single polled value.
## Gotchas
| Symptom | Cause | Fix |

View File

@@ -31,14 +31,10 @@ export class VueNodeHelpers {
}
/**
* Get locator for Vue nodes by the node's title (displayed name in the header).
* Matches against the actual title element, not the full node body.
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
* Get locator for a Vue node by the node's title (displayed name in the header)
*/
getNodeByTitle(title: string): Locator {
return this.page.locator('[data-node-id]').filter({
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
})
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
}
/**

View File

@@ -30,6 +30,10 @@ export class BuilderFooterHelper {
return this.page.getByTestId(TestIds.builder.saveButton)
}
get saveGroup(): Locator {
return this.page.getByTestId(TestIds.builder.saveGroup)
}
get saveAsButton(): Locator {
return this.page.getByTestId(TestIds.builder.saveAsButton)
}

View File

@@ -18,11 +18,7 @@ export class BuilderSelectHelper {
getInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({
has: this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
})
.filter({ hasText: title })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
@@ -32,7 +28,7 @@ export class BuilderSelectHelper {
*/
getPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.getByLabel(ariaLabel, { exact: true })
.locator(`[aria-label="${ariaLabel}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
}
@@ -70,7 +66,7 @@ export class BuilderSelectHelper {
async renameInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
.filter({ hasText: title })
await titleEl.dblclick()
const input = this.page

View File

@@ -169,39 +169,6 @@ export class CanvasHelper {
})
}
/**
* Pan the canvas back and forth in a sweep pattern using middle-mouse drag.
* Each step advances one animation frame, giving per-frame measurement
* granularity for performance tests.
*/
async panSweep(options?: {
steps?: number
dx?: number
dy?: number
}): Promise<void> {
const { steps = 120, dx = 8, dy = 3 } = options ?? {}
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await this.page.mouse.move(centerX, centerY)
await this.page.mouse.down({ button: 'middle' })
// Sweep forward
for (let i = 0; i < steps; i++) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
}
// Sweep back
for (let i = steps; i > 0; i--) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
}
await this.page.mouse.up({ button: 'middle' })
}
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,

View File

@@ -1,11 +1,10 @@
import { expect } from '@playwright/test'
import type { ConsoleMessage, Locator, Page } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
CanvasPointerEvent,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
@@ -414,138 +413,4 @@ export class SubgraphHelper {
return window.app!.canvas.graph!.nodes?.length || 0
})
}
async getSlotCount(type: 'input' | 'output'): Promise<number> {
return this.page.evaluate((slotType: 'input' | 'output') => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return 0
return graph[`${slotType}s`]?.length ?? 0
}, type)
}
async getSlotLabel(
type: 'input' | 'output',
index = 0
): Promise<string | null> {
return this.page.evaluate(
([slotType, idx]) => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const slot = graph[`${slotType}s`]?.[idx]
return slot?.label ?? slot?.name ?? null
},
[type, index] as const
)
}
async removeSlot(type: 'input' | 'output', slotName?: string): Promise<void> {
if (type === 'input') {
await this.rightClickInputSlot(slotName)
} else {
await this.rightClickOutputSlot(slotName)
}
await this.comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await this.comfyPage.nextFrame()
}
async findSubgraphNodeId(): Promise<string> {
const id = await this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const node = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return node ? String(node.id) : null
})
if (!id) throw new Error('No subgraph node found in current graph')
return id
}
async serializeAndReload(): Promise<void> {
const serialized = await this.page.evaluate(() =>
window.app!.graph!.serialize()
)
await this.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized as ComfyWorkflowJSON
)
await this.comfyPage.nextFrame()
}
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
await this.comfyPage.workflow.loadWorkflow('default')
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await this.comfyPage.nextFrame()
return subgraphNode
}
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.click()
await this.comfyPage.canvas.press('Control+a')
await this.comfyPage.nextFrame()
await this.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await this.comfyPage.nextFrame()
await this.exitViaBreadcrumb()
await this.comfyPage.canvas.click()
await this.comfyPage.nextFrame()
}
static getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}
static async expectWidgetBelowHeader(
nodeLocator: Locator,
widgetLocator: Locator
): Promise<void> {
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await widgetLocator.boundingBox()
if (!headerBox || !widgetBox)
throw new Error('Header or widget bounding box not found')
expect(widgetBox.y).toBeGreaterThan(headerBox.y + headerBox.height)
}
static collectConsoleWarnings(
page: Page,
patterns: string[] = [
'No link found',
'Failed to resolve legacy -1',
'No inner link found'
]
): { warnings: string[]; dispose: () => void } {
const warnings: string[] = []
const handler = (msg: ConsoleMessage) => {
const text = msg.text()
if (patterns.some((p) => text.includes(p))) {
warnings.push(text)
}
}
page.on('console', handler)
return { warnings, dispose: () => page.off('console', handler) }
}
}

View File

@@ -80,6 +80,7 @@ export const TestIds = {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',
saveAsButton: 'builder-save-as-button',
saveGroup: 'builder-save-group',
saveAsChevron: 'builder-save-as-chevron',
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',

View File

@@ -392,11 +392,6 @@ export class NodeReference {
await this.comfyPage.clipboard.copy()
await this.comfyPage.nextFrame()
}
async delete(): Promise<void> {
await this.click('title')
await this.comfyPage.page.keyboard.press('Delete')
await this.comfyPage.nextFrame()
}
async connectWidget(
originSlotIndex: number,
targetNode: NodeReference,

View File

@@ -12,38 +12,6 @@ export interface PerfReport {
const TEMP_DIR = join('test-results', 'perf-temp')
type MeasurementField = keyof PerfMeasurement
const FIELD_FORMATTERS: Record<string, (m: PerfMeasurement) => string> = {
styleRecalcs: (m) => `${m.styleRecalcs} recalcs`,
layouts: (m) => `${m.layouts} layouts`,
taskDurationMs: (m) => `${m.taskDurationMs.toFixed(1)}ms task`,
layoutDurationMs: (m) => `${m.layoutDurationMs.toFixed(1)}ms layout`,
frameDurationMs: (m) => `${m.frameDurationMs.toFixed(1)}ms/frame`,
totalBlockingTimeMs: (m) => `TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`,
durationMs: (m) => `${m.durationMs.toFixed(0)}ms total`,
heapDeltaBytes: (m) => `heap Δ${(m.heapDeltaBytes / 1024).toFixed(0)}KB`,
domNodes: (m) => `DOM Δ${m.domNodes}`,
heapUsedBytes: (m) => `heap ${(m.heapUsedBytes / 1024 / 1024).toFixed(1)}MB`
}
/**
* Log a perf measurement to the console in a consistent format.
* Fields are formatted automatically based on their type.
*/
export function logMeasurement(
label: string,
m: PerfMeasurement,
fields: MeasurementField[]
) {
const parts = fields.map((f) => {
const formatter = FIELD_FORMATTERS[f]
if (formatter) return formatter(m)
return `${f}=${m[f]}`
})
console.log(`${label}: ${parts.join(', ')}`)
}
export function recordMeasurement(m: PerfMeasurement) {
mkdirSync(TEMP_DIR, { recursive: true })
const filename = `${m.name}-${Date.now()}.json`

View File

@@ -0,0 +1,45 @@
import type { Page } from '@playwright/test'
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
/**
* Assertion helper for tests where being in a subgraph is a precondition.
* Throws a clear error if the graph is not a Subgraph.
*/
export function assertSubgraph(
graph: LGraph | Subgraph | null | undefined
): asserts graph is Subgraph {
if (!isSubgraph(graph)) {
throw new Error(
'Expected to be in a subgraph context, but graph is not a Subgraph'
)
}
}
/**
* Returns the widget-input slot Y position and the node title height
* for the promoted "text" input on the SubgraphNode.
*
* The slot Y should be at the widget row, not the header. A value near
* zero or negative indicates the slot is positioned at the header (the bug).
*/
export function getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}

View File

@@ -19,26 +19,24 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
.toBe(`*${workflowName} - ComfyUI`)
})
test('Can display workflow name with unsaved changes', async ({
// Failing on CI
// Cannot reproduce locally
test.skip('Can display workflow name with unsaved changes', async ({
comfyPage
}) => {
const workflowName = `test-${Date.now()}`
await comfyPage.menu.topbar.saveWorkflow(workflowName)
await expect
.poll(() => comfyPage.page.title())
.toBe(`${workflowName} - ComfyUI`)
await comfyPage.page.evaluate(async () => {
const node = window.app!.graph!.nodes[0]
node.pos[0] += 50
window.app!.graph!.setDirtyCanvas(true, true)
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker?.checkState()
const workflowName = await comfyPage.page.evaluate(async () => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.filename
})
await expect
.poll(() => comfyPage.page.title())
.toBe(`*${workflowName} - ComfyUI`)
expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
await comfyPage.menu.topbar.saveWorkflow('test')
expect(await comfyPage.page.title()).toBe('test - ComfyUI')
const textBox = comfyPage.widgetTextBox
await textBox.fill('Hello World')
await comfyPage.canvasOps.clickEmptySpace()
expect(await comfyPage.page.title()).toBe(`*test - ComfyUI`)
// Delete the saved workflow for cleanup.
await comfyPage.page.evaluate(async () => {

View File

@@ -189,6 +189,41 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(saveAs.nameInput).toBeVisible()
})
test('Save button width is consistent across all states', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
// State 1: Disabled "Save as" (no outputs selected)
const disabledBox = await appMode.footer.saveAsButton.boundingBox()
expect(disabledBox).toBeTruthy()
// Select I/O to enable the button
await appMode.steps.goToInputs()
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await appMode.select.selectInputWidget(ksampler)
await appMode.steps.goToOutputs()
await appMode.select.selectOutputNode()
// State 2: Enabled "Save as" (unsaved, has outputs)
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
expect(enabledBox).toBeTruthy()
expect(enabledBox!.width).toBe(disabledBox!.width)
// Save the workflow to transition to the Save + chevron state
await builderSaveAs(appMode, `${Date.now()} width-test`, 'App')
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
// State 3: Save + chevron button group (saved workflow)
const saveButtonGroupBox = await appMode.footer.saveGroup.boundingBox()
expect(saveButtonGroupBox).toBeTruthy()
expect(saveButtonGroupBox!.width).toBe(disabledBox!.width)
})
test('Connect output popover appears when no outputs selected', async ({
comfyPage
}) => {

View File

@@ -256,6 +256,27 @@ test.describe('Missing models in Error Tab', () => {
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages)
).not.toBeVisible()
})
// Flaky test after parallelization
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
test.skip('Should download missing model when clicking download button', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await downloadAllButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
})
})
test.describe('Settings', () => {

View File

@@ -55,4 +55,46 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
const finalCount = await comfyPage.getDOMWidgetCount()
expect(finalCount).toBe(initialCount + 1)
})
test('should reposition when layout changes', async ({ comfyPage }) => {
test.skip(
true,
'Only recalculates when the Canvas size changes, need to recheck the logic'
)
// --- setup ---
const textareaWidget = comfyPage.page
.locator('.comfy-multiline-input')
.first()
await expect(textareaWidget).toBeVisible()
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.nextFrame()
let oldPos: [number, number]
const checkBboxChange = async () => {
const boudningBox = (await textareaWidget.boundingBox())!
expect(boudningBox).not.toBeNull()
const position: [number, number] = [boudningBox.x, boudningBox.y]
expect(position).not.toEqual(oldPos)
oldPos = position
}
await checkBboxChange()
// --- test ---
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.nextFrame()
await checkBboxChange()
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.nextFrame()
await checkBboxChange()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Bottom')
await comfyPage.nextFrame()
await checkBboxChange()
})
})

View File

@@ -94,7 +94,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
.click()
})
})
test(
// The 500ms fixed delay on the search results is causing flakiness
// Potential solution: add a spinner state when the search is in progress,
// and observe that state from the test. Blocker: the PrimeVue AutoComplete
// does not have a v-model on the query, so we cannot observe the raw
// query update, and thus cannot set the spinning state between the raw query
// update and the debounced search update.
test.skip(
'Can be added to canvas using search',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
@@ -102,16 +108,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(groupNodeName)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${groupNodeName}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -175,9 +175,7 @@ test.describe('Node Interaction', () => {
// Move mouse away to avoid hover highlight on the node at the drop position.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png', {
maxDiffPixels: 50
})
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
@@ -222,7 +220,10 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('moved-link.png')
})
test('Can copy link by shift-drag existing link', async ({ comfyPage }) => {
// Shift drag copy link regressed. See https://github.com/Comfy-Org/ComfyUI_frontend/issues/2941
test.skip('Can copy link by shift-drag existing link', async ({
comfyPage
}) => {
await comfyPage.canvasOps.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
@@ -814,15 +815,11 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await expect
.poll(() => comfyPage.menu.topbar.getTabNames(), { timeout: 5000 })
.toEqual(expect.arrayContaining([workflowA, workflowB]))
const tabs = await comfyPage.menu.topbar.getTabNames()
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -68,7 +68,7 @@ test.describe(
})
})
test('Load workflow from URL dropped onto Vue node', async ({
test.fixme('Load workflow from URL dropped onto Vue node', async ({
comfyPage
}) => {
const fakeUrl = 'https://example.com/workflow.png'

View File

@@ -481,7 +481,6 @@ This is English documentation.
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.waitFor({ state: 'visible', timeout: 10_000 })
await helpButton.click()
const helpPage = comfyPage.page.locator(

View File

@@ -176,13 +176,40 @@ test.describe('Node search box', { tag: '@node' }, () => {
await expectFilterChips(comfyPage, ['MODEL'])
})
test('Outer click dismisses filter panel but keeps search box visible', async ({
// Flaky test.
// Sample test failure:
// https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/12696912248/job/35391990861?pr=2210
/*
1) [chromium-2x] nodeSearchBox.spec.ts:135:5 Node search box Filtering Outer click dismisses filter panel but keeps search box visible
Error: expect(locator).not.toBeVisible()
Locator: getByRole('dialog').locator('div').filter({ hasText: 'Add node filter condition' })
Expected: not visible
Received: visible
Call log:
- expect.not.toBeVisible with timeout 5000ms
- waiting for getByRole('dialog').locator('div').filter({ hasText: 'Add node filter condition' })
143 |
144 | // Verify the filter selection panel is hidden
> 145 | expect(panel.header).not.toBeVisible()
| ^
146 |
147 | // Verify the node search dialog is still visible
148 | expect(comfyPage.searchBox.input).toBeVisible()
at /home/runner/work/ComfyUI_frontend/ComfyUI_frontend/ComfyUI_frontend/browser_tests/nodeSearchBox.spec.ts:145:32
*/
test.skip('Outer click dismisses filter panel but keeps search box visible', async ({
comfyPage
}) => {
await comfyPage.searchBox.filterButton.click()
const panel = comfyPage.searchBox.filterSelectionPanel
await panel.header.waitFor({ state: 'visible' })
await comfyPage.page.keyboard.press('Escape')
const panelBounds = await panel.header.boundingBox()
await comfyPage.page.mouse.click(panelBounds!.x - 10, panelBounds!.y - 10)
// Verify the filter selection panel is hidden
await expect(panel.header).not.toBeVisible()

View File

@@ -1,7 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { logMeasurement, recordMeasurement } from '../helpers/perfReporter'
import { recordMeasurement } from '../helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {
@@ -186,22 +186,6 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
)
})
test('large graph viewport pan sweep', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
await comfyPage.perf.startMeasuring()
await comfyPage.canvasOps.panSweep()
const measurement = await comfyPage.perf.stopMeasuring('viewport-pan-sweep')
recordMeasurement(measurement)
logMeasurement('Viewport pan sweep', measurement, [
'styleRecalcs',
'layouts',
'taskDurationMs',
'heapDeltaBytes',
'domNodes'
])
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {

View File

@@ -271,11 +271,9 @@ test.describe('Workflows sidebar', () => {
'.comfyui-workflows-open .close-workflow-button'
)
await closeButton.click()
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames(), {
timeout: 5000
})
.toEqual(['*Unsaved Workflow'])
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow'
])
})
test('Can close saved workflow with command', async ({ comfyPage }) => {

View File

@@ -110,11 +110,16 @@ test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
const isInSubgraph = () =>
comfyPage.page.evaluate(
() => window.app!.canvas.graph?.isRootGraph === false
)
expect(await isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
expect(await isInSubgraph()).toBe(false)
})
})

View File

@@ -1,7 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
test.describe(
'Subgraph promoted widget-input slot position',
@@ -18,10 +18,7 @@ test.describe(
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
const result = await getTextSlotPosition(comfyPage.page, '11')
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
@@ -40,10 +37,7 @@ test.describe(
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
const before = await getTextSlotPosition(comfyPage.page, '11')
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
@@ -79,10 +73,7 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
const after = await getTextSlotPosition(comfyPage.page, '11')
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)

View File

@@ -1,7 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
test.describe(
@@ -15,8 +14,13 @@ test.describe(
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.workflow.loadWorkflow('default')
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
@@ -39,7 +43,15 @@ test.describe(
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
// Widget top should be below the header bottom
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
})
}
)

View File

@@ -25,7 +25,11 @@ test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
await subgraphNode.navigateIntoSubgraph()
// Get initial slot label
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.label || graph.inputs?.[0]?.name || null
})
if (initialInputLabel === null) {
throw new Error(
@@ -102,7 +106,11 @@ test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
await comfyPage.nextFrame()
// Verify the second rename worked
const afterSecondRename = await comfyPage.subgraph.getSlotLabel('input')
const afterSecondRename = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.label || null
})
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
})
@@ -115,7 +123,11 @@ test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
await subgraphNode.navigateIntoSubgraph()
// Get initial output slot label
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
const initialOutputLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.outputs?.[0]?.label || graph.outputs?.[0]?.name || null
})
if (initialOutputLabel === null) {
throw new Error(

View File

@@ -26,6 +26,38 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
)
})
// Helper to get subgraph slot count
async function getSubgraphSlotCount(
comfyPage: typeof test.prototype.comfyPage,
type: 'inputs' | 'outputs'
): Promise<number> {
return await comfyPage.page.evaluate((slotType: 'inputs' | 'outputs') => {
const graph = window.app!.canvas.graph
// isSubgraph check: subgraphs have isRootGraph === false
if (!graph || !('inputNode' in graph)) return 0
return graph[slotType]?.length || 0
}, type)
}
// Helper to get current graph node count
async function getGraphNodeCount(
comfyPage: typeof test.prototype.comfyPage
): Promise<number> {
return await comfyPage.page.evaluate(() => {
return window.app!.canvas.graph!.nodes?.length || 0
})
}
// Helper to verify we're in a subgraph
async function isInSubgraph(
comfyPage: typeof test.prototype.comfyPage
): Promise<boolean> {
return await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
test.describe('I/O Slot Management', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -33,7 +65,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('input')
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
@@ -42,7 +74,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('input')
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(finalCount).toBe(initialCount + 1)
})
@@ -52,7 +84,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('output')
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
@@ -61,7 +93,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('output')
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(finalCount).toBe(initialCount + 1)
})
@@ -71,16 +103,18 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('input')
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('input')
await comfyPage.subgraph.rightClickInputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('input')
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(finalCount).toBe(initialCount - 1)
})
@@ -90,16 +124,18 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await comfyPage.subgraph.getSlotCount('output')
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('output')
await comfyPage.subgraph.rightClickOutputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('output')
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(finalCount).toBe(initialCount - 1)
})
@@ -109,7 +145,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.label || null
})
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
@@ -125,7 +165,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
@@ -137,7 +181,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.label || null
})
await comfyPage.subgraph.doubleClickInputSlot(initialInputLabel!)
@@ -151,7 +199,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
@@ -163,7 +215,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
const initialOutputLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.outputs?.[0]?.label || null
})
await comfyPage.subgraph.doubleClickOutputSlot(initialOutputLabel!)
@@ -178,7 +234,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newOutputName = await comfyPage.subgraph.getSlotLabel('output')
const newOutputName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.outputs?.[0]?.label || null
})
expect(newOutputName).toBe(renamedOutputName)
expect(newOutputName).not.toBe(initialOutputLabel)
@@ -192,7 +252,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.label || null
})
// Test that right-click still works for renaming
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
@@ -210,7 +274,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(rightClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
@@ -224,7 +292,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.label || null
})
// Use direct pointer event approach to double-click on label
await comfyPage.page.evaluate(() => {
@@ -282,7 +354,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(labelClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
@@ -354,7 +430,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
expect(subgraphNodes.length).toBe(1)
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(1)
})
@@ -364,11 +440,13 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
expect(await subgraphNode.exists()).toBe(true)
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const initialNodeCount = await getGraphNodeCount(comfyPage)
await subgraphNode.delete()
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(initialNodeCount - 1)
const deletedNode = await comfyPage.nodeOps.getNodeRefById('2')
@@ -445,7 +523,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const initialNodeCount = await getGraphNodeCount(comfyPage)
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodes = window.app!.canvas.graph!.nodes
@@ -466,7 +544,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.nextFrame()
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(initialNodeCount + 1)
})
@@ -482,20 +560,20 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
// Get initial node count
const initialCount = await comfyPage.subgraph.getNodeCount()
const initialCount = await getGraphNodeCount(comfyPage)
// Undo
await comfyPage.keyboard.undo()
await comfyPage.nextFrame()
const afterUndoCount = await comfyPage.subgraph.getNodeCount()
const afterUndoCount = await getGraphNodeCount(comfyPage)
expect(afterUndoCount).toBe(initialCount - 1)
// Redo
await comfyPage.keyboard.redo()
await comfyPage.nextFrame()
const afterRedoCount = await comfyPage.subgraph.getNodeCount()
const afterRedoCount = await getGraphNodeCount(comfyPage)
expect(afterRedoCount).toBe(initialCount)
})
})
@@ -565,17 +643,17 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
expect(await isInSubgraph(comfyPage)).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
expect(await isInSubgraph(comfyPage)).toBe(false)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
@@ -672,7 +750,9 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
@@ -699,7 +779,9 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
// Navigate into subgraph (method now handles retries internally)
await subgraphNode.navigateIntoSubgraph()
await comfyPage.subgraph.removeSlot('input', 'text')
await comfyPage.subgraph.rightClickInputSlot('text')
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
// Wait for breadcrumb to be visible
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
@@ -799,19 +881,19 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
expect(await isInSubgraph(comfyPage)).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
if (!(await comfyPage.subgraph.isInSubgraph())) {
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
@@ -825,7 +907,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
if (!(await comfyPage.subgraph.isInSubgraph())) {
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
@@ -845,12 +927,12 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
).not.toBeVisible()
// Should still be in subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
expect(await isInSubgraph(comfyPage)).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
expect(await isInSubgraph(comfyPage)).toBe(false)
})
})
})

View File

@@ -2,7 +2,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
const RENAMED_LABEL = 'my_seed'
@@ -41,7 +40,13 @@ test.describe(
await expect(seedWidget).toBeVisible()
// Verify widget is in the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(sgNode, seedWidget)
const headerBox = await sgNode
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
@@ -98,7 +103,15 @@ test.describe(
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await expect(seedWidgetAfter).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
const headerAfter = await sgNodeAfter
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetAfter = await seedWidgetAfter.boundingBox()
expect(headerAfter).not.toBeNull()
expect(widgetAfter).not.toBeNull()
expect(widgetAfter!.y).toBeGreaterThan(
headerAfter!.y + headerAfter!.height
)
})
}
)

View File

@@ -2,7 +2,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
import { TestIds } from '../fixtures/selectors'
/**
@@ -31,7 +30,17 @@ test.describe(
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
const { warnings } = SubgraphHelper.collectConsoleWarnings(comfyPage.page)
const warnings: string[] = []
comfyPage.page.on('console', (msg) => {
const text = msg.text()
if (
text.includes('Failed to resolve legacy -1') ||
text.includes('No link found') ||
text.includes('No inner link found')
) {
warnings.push(text)
}
})
await comfyPage.workflow.loadWorkflow(WORKFLOW)

View File

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
@@ -81,7 +82,14 @@ test.describe(
initialWidgets
)
await comfyPage.subgraph.serializeAndReload()
const serialized1 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized1 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterFirst = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
@@ -90,7 +98,14 @@ test.describe(
afterFirst
)
await comfyPage.subgraph.serializeAndReload()
const serialized2 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized2 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterSecond = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
@@ -147,7 +162,9 @@ test.describe(
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -187,7 +204,9 @@ test.describe(
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -278,9 +297,14 @@ test.describe(
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.delete()
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
expect(await subgraphNode.exists()).toBe(false)
const nodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.canvas.graph!.getNodeById('5')
})
expect(nodeExists).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())

View File

@@ -1,7 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
@@ -9,10 +8,17 @@ test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
comfyPage
}) => {
const { warnings } = SubgraphHelper.collectConsoleWarnings(comfyPage.page, [
'No link found',
'Failed to resolve legacy -1'
])
const warnings: string[] = []
comfyPage.page.on('console', (msg) => {
const text = msg.text()
if (
text.includes('No link found') ||
text.includes('Failed to resolve legacy -1') ||
text.includes('No inner link found')
) {
warnings.push(text)
}
})
await comfyPage.workflow.loadWorkflow(WORKFLOW)

View File

@@ -60,8 +60,29 @@ test.describe(
await expect(textWidget).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
// 2. Pack all interior nodes into a nested subgraph
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
// 2. Enter the subgraph via Vue node button
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
// 3. Disable Vue nodes for canvas operations (select all + convert)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
// 4. Select all interior nodes and convert to nested subgraph
await comfyPage.canvas.click()
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await comfyPage.nextFrame()
// 5. Navigate back to root graph and trigger a checkState cycle
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
// 6. Re-enable Vue nodes and verify values are preserved
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
@@ -102,8 +123,24 @@ test.describe(
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeLocator).toBeVisible()
// Pack all interior nodes into a nested subgraph
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
// Enter the subgraph via Vue node button, then disable for canvas ops
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.canvas.click()
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
// Verify all proxyWidgets entries resolve
await expect(async () => {

View File

@@ -13,7 +13,14 @@ test.describe(
await comfyPage.nextFrame()
// Find the subgraph node
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
// Simulate a stale progress value on the subgraph node.
// This happens when:
@@ -27,21 +34,26 @@ test.describe(
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.5
}, subgraphNodeId)
}, subgraphNodeId!)
// Verify progress is set
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId)
}, subgraphNodeId!)
expect(progressBefore).toBe(0.5)
// Navigate into the subgraph
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
@@ -68,18 +80,30 @@ test.describe(
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.7
}, subgraphNodeId)
}, subgraphNodeId!)
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()

View File

@@ -1,5 +1,7 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { fitToViewInstant } from '../helpers/fitToView'
@@ -70,7 +72,14 @@ test.describe(
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.centerOnNode()
const savePos = await saveNode.getPosition()
await comfyPage.page.evaluate((pos) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2
canvas.setDirty(true, true)
}, savePos)
await comfyPage.nextFrame()
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
@@ -351,7 +360,6 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(initialWidgetCount).toBeGreaterThan(0)
@@ -464,7 +472,14 @@ test.describe(
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.centerOnNode()
const savePos = await saveNode.getPosition()
await comfyPage.page.evaluate((pos) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2
canvas.setDirty(true, true)
}, savePos)
await comfyPage.nextFrame()
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
@@ -513,7 +528,14 @@ test.describe(
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(beforePromoted).toContain('text')
await comfyPage.subgraph.serializeAndReload()
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(afterPromoted).toContain('text')
@@ -533,7 +555,14 @@ test.describe(
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(beforeSnapshot.length).toBeGreaterThan(0)
await comfyPage.subgraph.serializeAndReload()
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(afterSnapshot).toEqual(beforeSnapshot)
@@ -685,10 +714,15 @@ test.describe(
// Delete the subgraph node
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
// Node no longer exists, so promoted widgets should be gone
expect(await subgraphNode.exists()).toBe(false)
const nodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.canvas.graph!.getNodeById('11')
})
expect(nodeExists).toBe(false)
})
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
@@ -714,7 +748,9 @@ test.describe(
})
expect(removedSlotName).not.toBeNull()
await comfyPage.subgraph.removeSlot('input')
await comfyPage.subgraph.rightClickInputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -744,7 +780,9 @@ test.describe(
await subgraphNode.navigateIntoSubgraph()
// Remove the text input slot
await comfyPage.subgraph.removeSlot('input', 'text')
await comfyPage.subgraph.rightClickInputSlot('text')
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
// Navigate back via breadcrumb
await comfyPage.subgraph.exitViaBreadcrumb()

View File

@@ -4,8 +4,19 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function createSubgraphAndNavigateInto(comfyPage: ComfyPage) {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
expect(subgraphNodes.length).toBe(1)
const subgraphNode = subgraphNodes[0]
await subgraphNode.navigateIntoSubgraph()
return subgraphNode
}

View File

@@ -2,7 +2,6 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function checkTemplateFileExists(
page: Page,
@@ -33,11 +32,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
}
})
// Flaky: /templates is proxied to an external server, so thumbnail
// availability varies across CI runs.
// FIX: Make hermetic — fixture index.json and thumbnail responses via
// page.route(), and change checkTemplateFileExists to use browser-context
// fetch (page.request.head bypasses Playwright routing).
// TODO: Re-enable this test once issue resolved
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
test.skip('should have all required thumbnail media for each template', async ({
comfyPage
@@ -77,9 +72,9 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
// Clear the workflow
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 250 })
.toBe(0)
await expect(async () => {
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 250 })
// Load a template
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
@@ -92,9 +87,9 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeHidden()
// Ensure we now have some nodes
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 250 })
.toBeGreaterThan(0)
await expect(async () => {
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBeGreaterThan(0)
}).toPass({ timeout: 250 })
})
test('dialog should be automatically shown to first-time users', async ({
@@ -107,7 +102,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await comfyPage.setup({ clearStorage: true })
// Expect the templates dialog to be shown
await expect(comfyPage.templates.content).toBeVisible({ timeout: 5000 })
expect(await comfyPage.templates.content.isVisible()).toBe(true)
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
@@ -346,71 +341,4 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
)
}
)
test(
'template cards display overlay tags correctly',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.page.route('**/templates/index.json', async (route) => {
const response = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'tagged-template',
title: 'Tagged Template',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'A template with tags.',
tags: ['Relight', 'Image Edit']
},
{
name: 'no-tags',
title: 'No Tags',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'A template without tags.'
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.page.route('**/templates/**.webp', async (route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
const taggedCard = comfyPage.page.getByTestId(
TestIds.templates.workflowCard('tagged-template')
)
await expect(taggedCard).toBeVisible({ timeout: 5000 })
await expect(taggedCard.getByText('Relight')).toBeVisible()
await expect(taggedCard.getByText('Image Edit')).toBeVisible()
const templateGrid = comfyPage.page.getByTestId(TestIds.templates.content)
await expect(templateGrid).toHaveScreenshot(
'template-cards-with-overlay-tags.png'
)
}
)
})

View File

@@ -25,37 +25,37 @@ test.describe('Vue Nodes Image Preview', () => {
dropPosition: { x, y }
})
const nodeId = String(loadImageNode.id)
const imagePreview = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.image-preview')
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible({ timeout: 30_000 })
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return {
imagePreview,
nodeId
nodeId: String(loadImageNode.id)
}
}
test('opens mask editor from image preview button', async ({ comfyPage }) => {
// TODO(#8143): Re-enable after image preview sync is working in CI
test.fixme('opens mask editor from image preview button', async ({
comfyPage
}) => {
const { imagePreview } = await loadImageOnNode(comfyPage)
await imagePreview.getByRole('region').hover()
await imagePreview.locator('[role="img"]').focus()
await comfyPage.page.getByLabel('Edit or mask image').click()
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})
test('shows image context menu options', async ({ comfyPage }) => {
// TODO(#8143): Re-enable after image preview sync is working in CI
test.fixme('shows image context menu options', async ({ comfyPage }) => {
const { nodeId } = await loadImageOnNode(comfyPage)
await comfyPage.vueNodes.selectNode(nodeId)
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-header')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })
const contextMenu = comfyPage.page.locator('.p-contextmenu')

View File

@@ -76,9 +76,10 @@ test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
await comfyPage.page.keyboard.press('r')
// Wait for nodes' widgets to be updated
await expect
.poll(() => getComboValues(), { timeout: 5000 })
.not.toEqual(initialComboValues)
await expect(async () => {
const refreshedComboValues = await getComboValues()
expect(refreshedComboValues).not.toEqual(initialComboValues)
}).toPass({ timeout: 5000 })
})
test('Should refresh combo values of nodes with v2 combo input spec', async ({
@@ -184,9 +185,7 @@ test.describe(
test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can load image', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await expect(comfyPage.canvas).toHaveScreenshot('load_image_widget.png', {
maxDiffPixels: 50
})
await expect(comfyPage.canvas).toHaveScreenshot('load_image_widget.png')
})
test('Can drag and drop image', async ({ comfyPage }) => {
@@ -228,23 +227,14 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
const comboEntry = comfyPage.page.getByRole('menuitem', {
name: 'image32x32.webp'
})
const imageLoaded = comfyPage.page.waitForResponse(
(resp) =>
resp.url().includes('/view') &&
resp.url().includes('image32x32.webp') &&
resp.request().method() === 'GET' &&
resp.status() === 200
)
await comboEntry.click()
// Wait for the image to load from the server
await imageLoaded
// Stabilization for the image swap
await comfyPage.nextFrame()
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'image_preview_changed_by_combo_value.png',
{ maxDiffPixels: 50 }
'image_preview_changed_by_combo_value.png'
)
// Expect the filename combo value to be updated
@@ -283,6 +273,38 @@ test.describe(
'Animated image widget',
{ tag: ['@screenshot', '@widget'] },
() => {
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3718
test.skip('Shows preview of uploaded animated image', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('widgets/load_animated_webp')
// Get position of the load animated webp node
const nodes = await comfyPage.nodeOps.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragDrop.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped.png'
)
// Move mouse and click on canvas to trigger render
await comfyPage.page.mouse.click(64, 64)
// Expect the image preview to change to the next frame of the animation
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped_next_frame.png'
)
})
test('Can drag-and-drop animated webp image', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_animated_webp')
@@ -337,11 +359,9 @@ test.describe(
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('.dom-widget').locator('img')
).toHaveCount(2, { timeout: 10_000 })
).toHaveCount(2)
})
}
)

View File

@@ -90,12 +90,10 @@ test.describe('Workflow Tab Thumbnails', { tag: '@workflow' }, () => {
const canvasArea = await comfyPage.canvas.boundingBox()
await comfyPage.page.mouse.move(
canvasArea!.x + canvasArea!.width / 2,
canvasArea!.y + canvasArea!.height / 2
)
await expect(comfyPage.page.locator('.workflow-popover-fade')).toHaveCount(
0
canvasArea!.x + canvasArea!.width - 100,
100
)
await expect(comfyPage.page.locator('.workflow-popover-fade')).toBeHidden()
await comfyPage.canvasOps.rightClick(200, 200)
await comfyPage.page.getByText('Add Node').click()

View File

@@ -100,7 +100,7 @@ test.describe('Zoom Controls', { tag: '@canvas' }, () => {
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 5000 })
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
.toBeCloseTo(1.0, 1)
const zoomIn = comfyPage.page.getByTestId(TestIds.canvas.zoomInAction)

View File

@@ -75,30 +75,6 @@ await page.evaluate(() => {
// Keep app.extensionManager typed as ExtensionManager, not WorkspaceStore
```
## Assertion Best Practices
When a test depends on an invariant unrelated to what it's actually testing (e.g. asserting a node has 4 widgets before testing node movement), always assert that invariant explicitly — don't leave it unchecked. Use a custom message or `expect.soft()` rather than a bare `expect`, so failures point to the broken assumption instead of producing a confusing error downstream.
```typescript
// ✅ Custom message on an unrelated precondition — clear signal when the invariant breaks
expect(node.widgets, 'Widget count changed — update test fixture').toHaveLength(
4
)
await node.move(100, 200)
// ✅ Soft assertion — verifies multiple invariants without stopping the test early
expect.soft(menuItem1).toBeVisible()
expect.soft(menuItem2).toBeVisible()
expect.soft(menuItem3).toBeVisible()
// ❌ Bare expect on a precondition — no context when it fails
expect(node.widgets).toHaveLength(4)
```
- Use custom messages (`expect(x, 'reason')`) for precondition checks unrelated to the test's purpose
- Use `expect.soft()` when you want to verify multiple invariants without aborting on the first failure
- Prefer Playwright's built-in message parameter over custom error classes
## Test Tags
Tags are respected by config:
@@ -110,7 +86,6 @@ Tags are respected by config:
- Check `browser_tests/assets/` for test data and fixtures
- Use realistic ComfyUI workflows for E2E tests
- When multiple nodes share the same title (e.g. two "CLIP Text Encode" nodes), use `vueNodes.getNodeByTitle(name).nth(n)` to pick a specific one. Never interact with the bare locator when titles are non-unique — Playwright strict mode will fail.
## Running Tests

View File

@@ -64,13 +64,6 @@ const commonParserOptions = {
extraFileExtensions
} as const
const useVirtualListRestriction = {
name: '@vueuse/core',
importNames: ['useVirtualList'],
message:
'useVirtualList requires uniform item heights. Use TanStack Virtual (via Reka UI virtualizer or @tanstack/vue-virtual) instead.'
} as const
export default defineConfig([
{
ignores: [
@@ -363,14 +356,6 @@ export default defineConfig([
}
},
// The website app is a marketing site with no vue-i18n setup
{
files: ['apps/website/**/*.vue'],
rules: {
'@intlify/vue-i18n/no-raw-text': 'off'
}
},
// i18n import enforcement
// Vue components must use the useI18n() composable, not the global t/d/st/te
{
@@ -385,8 +370,7 @@ export default defineConfig([
importNames: ['t', 'd', 'te'],
message:
"In Vue components, use `const { t } = useI18n()` instead of importing from '@/i18n'."
},
useVirtualListRestriction
}
]
}
]
@@ -406,23 +390,10 @@ export default defineConfig([
importNames: ['useI18n'],
message:
"useI18n() requires Vue setup context. Use `import { t } from '@/i18n'` instead."
},
useVirtualListRestriction
}
]
}
]
}
},
// Preserve the useVirtualList ban for files excluded from the useI18n rule.
{
files: ['**/use[A-Z]*.ts', '**/*.test.ts', 'src/i18n.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [useVirtualListRestriction]
}
]
}
}
])

View File

@@ -36,6 +36,7 @@
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",
"preinstall": "pnpm dlx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook",
@@ -199,21 +200,11 @@
"zod-to-json-schema": "catalog:"
},
"engines": {
"node": "24.x",
"pnpm": ">=10"
"node": "24.x"
},
"packageManager": "pnpm@10.33.0",
"pnpm": {
"overrides": {
"vite": "catalog:"
},
"ignoredBuiltDependencies": [
"@firebase/util",
"core-js",
"protobufjs",
"sharp",
"unrs-resolver",
"vue-demi"
]
}
}
}

View File

@@ -1,38 +0,0 @@
@theme {
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
--color-charcoal-500: #2d2e32;
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-ash-300: #bbbbbb;
--color-ash-500: #828282;
--color-ash-800: #444444;
--color-smoke-100: #f3f3f3;
--color-smoke-200: #e9e9e9;
--color-smoke-300: #e1e1e1;
--color-smoke-400: #d9d9d9;
--color-smoke-500: #c5c5c5;
--color-smoke-600: #b4b4b4;
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
--color-white: #ffffff;
--color-black: #000000;
/* Brand Colors */
--color-electric-400: #f0ff41;
--color-sapphire-700: #172dd7;
--color-brand-yellow: var(--color-electric-400);
--color-brand-blue: var(--color-sapphire-700);
}

View File

@@ -5,4 +5,42 @@
*/
@import './fonts.css';
@import './_palette.css';
@theme {
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
--color-charcoal-500: #2d2e32;
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-ash-300: #bbbbbb;
--color-ash-500: #828282;
--color-ash-800: #444444;
--color-smoke-100: #f3f3f3;
--color-smoke-200: #e9e9e9;
--color-smoke-300: #e1e1e1;
--color-smoke-400: #d9d9d9;
--color-smoke-500: #c5c5c5;
--color-smoke-600: #b4b4b4;
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
--color-white: #ffffff;
--color-black: #000000;
/* Brand Colors */
--color-electric-400: #f0ff41;
--color-sapphire-700: #172dd7;
--color-brand-yellow: var(--color-electric-400);
--color-brand-blue: var(--color-sapphire-700);
}

View File

@@ -1,7 +1,6 @@
@layer theme, base, primevue, components, utilities;
@import './fonts.css';
@import './_palette.css';
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/utilities' layer(utilities);
@import 'tw-animate-css';
@@ -32,10 +31,38 @@
--text-3xs: 0.5625rem;
--text-3xs--line-height: calc(1 / 0.5625);
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
--color-charcoal-500: #2d2e32;
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-ash-300: #bbbbbb;
--color-ash-500: #828282;
--color-ash-800: #444444;
--color-ivory-100: #fdfbfa;
--color-ivory-200: #faf9f5;
--color-ivory-300: #f0eee6;
--color-smoke-100: #f3f3f3;
--color-smoke-200: #e9e9e9;
--color-smoke-300: #e1e1e1;
--color-smoke-400: #d9d9d9;
--color-smoke-500: #c5c5c5;
--color-smoke-600: #b4b4b4;
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
--color-sand-100: #e1ded5;
--color-sand-200: #fff7d5;
--color-sand-300: #888682;
@@ -45,6 +72,14 @@
--color-slate-200: #9fa2bd;
--color-slate-300: #5b5e7d;
--color-white: #ffffff;
--color-black: #000000;
--color-electric-400: #f0ff41;
--color-sapphire-700: #172dd7;
--color-brand-yellow: var(--color-electric-400);
--color-brand-blue: var(--color-sapphire-700);
--color-azure-300: #78bae9;
--color-azure-400: #31b9f4;
--color-azure-600: #0b8ce9;

View File

@@ -357,7 +357,6 @@ export type {
HubUsernameCheckResponse,
HubWorkflowDetail,
HubWorkflowListResponse,
HubWorkflowStatus,
HubWorkflowSummary,
HubWorkflowTemplateEntry,
ImportPublishedAssetsData,
@@ -511,13 +510,6 @@ export type {
SendUserInviteEmailResponse,
SendUserInviteEmailResponse2,
SendUserInviteEmailResponses,
SetReviewStatusData,
SetReviewStatusError,
SetReviewStatusErrors,
SetReviewStatusRequest,
SetReviewStatusResponse,
SetReviewStatusResponse2,
SetReviewStatusResponses,
SubmitFeedbackData,
SubmitFeedbackError,
SubmitFeedbackErrors,

View File

@@ -151,7 +151,6 @@ export type HubWorkflowDetail = {
share_id: string
workflow_id: string
name: string
status: HubWorkflowStatus
description?: string
tags?: Array<LabelRef>
thumbnail_type?: 'image' | 'video' | 'image_comparison'
@@ -195,19 +194,9 @@ export type LabelRef = {
display_name: string
}
/**
* Public workflow status. NULL in the database is represented as pending in API responses.
*/
export type HubWorkflowStatus =
| 'pending'
| 'approved'
| 'rejected'
| 'deprecated'
export type HubWorkflowSummary = {
share_id: string
name: string
status: HubWorkflowStatus
description?: string
tags?: Array<LabelRef>
models?: Array<LabelRef>
@@ -256,7 +245,6 @@ export type HubWorkflowTemplateEntry = {
*/
name: string
title: string
status: HubWorkflowStatus
description?: string
tags?: Array<string>
models?: Array<string>
@@ -1641,28 +1629,6 @@ export type SendUserInviteEmailRequest = {
force?: boolean
}
export type SetReviewStatusResponse = {
/**
* The share IDs that were submitted for review
*/
share_ids: Array<string>
/**
* The applied review status
*/
status: 'approved' | 'rejected'
}
export type SetReviewStatusRequest = {
/**
* The share IDs of the hub workflows to review
*/
share_ids: Array<string>
/**
* The review decision for the workflows
*/
status: 'approved' | 'rejected'
}
/**
* Response after successfully claiming an invite code
*/
@@ -4491,45 +4457,6 @@ export type SendUserInviteEmailResponses = {
export type SendUserInviteEmailResponse2 =
SendUserInviteEmailResponses[keyof SendUserInviteEmailResponses]
export type SetReviewStatusData = {
body: SetReviewStatusRequest
path?: never
query?: never
url: '/admin/api/hub/workflows/status'
}
export type SetReviewStatusErrors = {
/**
* Bad request - invalid status value or empty share_ids
*/
400: ErrorResponse
/**
* Unauthorized - authentication required
*/
401: ErrorResponse
/**
* Forbidden - insufficient permissions
*/
403: ErrorResponse
/**
* Internal server error
*/
500: ErrorResponse
}
export type SetReviewStatusError =
SetReviewStatusErrors[keyof SetReviewStatusErrors]
export type SetReviewStatusResponses = {
/**
* Status updated successfully
*/
200: SetReviewStatusResponse
}
export type SetReviewStatusResponse2 =
SetReviewStatusResponses[keyof SetReviewStatusResponses]
export type GetDeletionRequestData = {
body?: never
path?: never
@@ -5813,10 +5740,6 @@ export type ListHubWorkflowsData = {
* When true, returns full HubWorkflowDetail objects in the workflows array instead of summaries. Requires limit <= 20.
*/
detail?: boolean
/**
* Filter by status (e.g. ?status=pending,approved). Defaults to approved if omitted.
*/
status?: Array<HubWorkflowStatus>
}
url: '/api/hub/workflows'
}
@@ -5891,12 +5814,7 @@ export type PublishHubWorkflowResponse =
export type ListHubWorkflowIndexData = {
body?: never
path?: never
query?: {
/**
* Filter by status (e.g. ?status=pending,approved). Defaults to approved if omitted.
*/
status?: Array<HubWorkflowStatus>
}
query?: never
url: '/api/hub/workflows/index'
}

View File

@@ -58,21 +58,10 @@ export const zLabelRef = z.object({
display_name: z.string()
})
/**
* Public workflow status. NULL in the database is represented as pending in API responses.
*/
export const zHubWorkflowStatus = z.enum([
'pending',
'approved',
'rejected',
'deprecated'
])
export const zHubWorkflowDetail = z.object({
share_id: z.string(),
workflow_id: z.string(),
name: z.string(),
status: zHubWorkflowStatus,
description: z.string().optional(),
tags: z.array(zLabelRef).optional(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
@@ -92,7 +81,6 @@ export const zHubWorkflowDetail = z.object({
export const zHubWorkflowSummary = z.object({
share_id: z.string(),
name: z.string(),
status: zHubWorkflowStatus,
description: z.string().optional(),
tags: z.array(zLabelRef).optional(),
models: z.array(zLabelRef).optional(),
@@ -126,7 +114,6 @@ export const zHubLabelListResponse = z.object({
export const zHubWorkflowTemplateEntry = z.object({
name: z.string(),
title: z.string(),
status: zHubWorkflowStatus,
description: z.string().optional(),
tags: z.array(z.string()).optional(),
models: z.array(z.string()).optional(),
@@ -883,16 +870,6 @@ export const zSendUserInviteEmailRequest = z.object({
force: z.boolean().optional().default(false)
})
export const zSetReviewStatusResponse = z.object({
share_ids: z.array(z.string()),
status: z.enum(['approved', 'rejected'])
})
export const zSetReviewStatusRequest = z.object({
share_ids: z.array(z.string()).min(1),
status: z.enum(['approved', 'rejected'])
})
/**
* Response after successfully claiming an invite code
*/
@@ -1860,17 +1837,6 @@ export const zSendUserInviteEmailData = z.object({
*/
export const zSendUserInviteEmailResponse2 = zSendUserInviteEmailResponse
export const zSetReviewStatusData = z.object({
body: zSetReviewStatusRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Status updated successfully
*/
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
export const zGetDeletionRequestData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2292,8 +2258,7 @@ export const zListHubWorkflowsData = z.object({
search: z.string().optional(),
tag: z.string().optional(),
username: z.string().optional(),
detail: z.boolean().optional().default(false),
status: z.array(zHubWorkflowStatus).optional()
detail: z.boolean().optional().default(false)
})
.optional()
})
@@ -2317,11 +2282,7 @@ export const zPublishHubWorkflowResponse = zHubWorkflowDetail
export const zListHubWorkflowIndexData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
status: z.array(zHubWorkflowStatus).optional()
})
.optional()
query: z.never().optional()
})
/**

File diff suppressed because it is too large Load Diff

View File

@@ -48,13 +48,16 @@
</ConnectOutputPopover>
<ButtonGroup
v-else-if="isSaved"
data-testid="builder-save-group"
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
>
<Button
size="lg"
:disabled="!isModified"
class="flex-1"
:class="isModified ? activeSaveClasses : disabledSaveClasses"
:class="
cn('w-24', isModified ? activeSaveClasses : disabledSaveClasses)
"
data-testid="builder-save-button"
@click="save()"
>
@@ -97,7 +100,7 @@
<Button
v-else
size="lg"
:class="activeSaveClasses"
:class="cn('w-24', activeSaveClasses)"
data-testid="builder-save-as-button"
@click="saveAs()"
>

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Tag from '@/components/chip/Tag.vue'
import SquareChip from '../chip/SquareChip.vue'
import CardBottom from './CardBottom.vue'
import CardContainer from './CardContainer.vue'
import CardDescription from './CardDescription.vue'
@@ -174,7 +174,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
CardTitle,
CardDescription,
Button,
Tag
SquareChip
},
setup() {
const favorited = ref(false)
@@ -218,7 +218,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template>
<template v-if="args.showTopLeft" #top-left>
<Tag label="Featured" />
<SquareChip label="Featured" />
</template>
<template v-if="args.showTopRight" #top-right>
@@ -238,17 +238,17 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template>
<template v-if="args.showBottomLeft" #bottom-left>
<Tag label="New" />
<SquareChip label="New" />
</template>
<template v-if="args.showBottomRight" #bottom-right>
<Tag v-if="args.showFileType" :label="args.fileType" />
<Tag v-if="args.showFileSize" :label="args.fileSize" />
<Tag v-for="tag in args.tags" :key="tag" :label="tag">
<SquareChip v-if="args.showFileType" :label="args.fileType" />
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
<template v-if="tag === 'LoRA'" #icon>
<i class="icon-[lucide--folder] size-3" />
</template>
</Tag>
</SquareChip>
</template>
</CardTop>
</template>

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SquareChip from './SquareChip.vue'
const meta: Meta<typeof SquareChip> = {
title: 'Components/SquareChip',
component: SquareChip,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text',
defaultValue: 'Tag'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const TagList: Story = {
render: () => ({
components: { SquareChip },
template: `
<div class="flex flex-wrap gap-2">
<SquareChip label="JavaScript" />
<SquareChip label="TypeScript" />
<SquareChip label="Vue.js" />
<SquareChip label="React" />
<SquareChip label="Node.js" />
<SquareChip label="Python" />
<SquareChip label="Docker" />
<SquareChip label="Kubernetes" />
</div>
`
})
}

View File

@@ -0,0 +1,31 @@
<template>
<div :class="chipClasses">
<slot name="icon"></slot>
<span>{{ label }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { label, variant = 'dark' } = defineProps<{
label: string
variant?: 'dark' | 'light' | 'gray'
}>()
const baseClasses =
'inline-flex shrink-0 items-center justify-center gap-1 rounded px-2 py-1 text-xs font-bold'
const variantStyles = {
dark: 'bg-zinc-500/40 text-white/90',
light: cn('bg-base-background/50 text-base-foreground backdrop-blur-[2px]'),
gray: cn(
'bg-modal-card-tag-background text-base-foreground backdrop-blur-[2px]'
)
}
const chipClasses = computed(() => {
return cn(baseClasses, variantStyles[variant])
})
</script>

View File

@@ -1,104 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Tag from './Tag.vue'
const meta: Meta<typeof Tag> = {
title: 'Components/Tag',
component: Tag,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
shape: {
control: 'select',
options: ['square', 'rounded', 'overlay']
},
state: {
control: 'select',
options: ['default', 'unselected', 'selected']
},
removable: { control: 'boolean' }
},
args: {
label: 'Tag',
shape: 'square',
state: 'default',
removable: false
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Rounded: Story = {
args: {
label: 'Tag',
shape: 'rounded'
}
}
export const Unselected: Story = {
args: {
label: 'Tag',
state: 'unselected'
}
}
export const Removable: Story = {
args: {
label: 'Tag',
removable: true
}
}
export const AllStates: Story = {
render: () => ({
components: { Tag },
template: `
<div class="flex flex-col gap-4">
<div>
<p class="mb-2 text-xs text-muted-foreground">Square</p>
<div class="flex items-center gap-2">
<Tag label="Default" />
<Tag label="Unselected" state="unselected" />
<Tag label="Removable" removable />
</div>
</div>
<div>
<p class="mb-2 text-xs text-muted-foreground">Rounded</p>
<div class="flex items-center gap-2">
<Tag label="Default" shape="rounded" />
<Tag label="Unselected" shape="rounded" state="unselected" />
<Tag label="Removable" shape="rounded" removable />
</div>
</div>
<div class="bg-zinc-800 p-2 rounded">
<p class="mb-2 text-xs text-muted-foreground">Overlay (on images)</p>
<div class="flex items-center gap-2">
<Tag label="png" shape="overlay" />
<Tag label="1.2 MB" shape="overlay" />
</div>
</div>
</div>
`
})
}
export const TagList: Story = {
render: () => ({
components: { Tag },
template: `
<div class="flex flex-wrap gap-2">
<Tag label="JavaScript" />
<Tag label="TypeScript" />
<Tag label="Vue.js" />
<Tag label="React" />
<Tag label="Node.js" />
<Tag label="Python" />
<Tag label="Docker" />
<Tag label="Kubernetes" />
</div>
`
})
}

View File

@@ -1,66 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import Tag from './Tag.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { remove: 'Remove' } } }
})
function renderTag(
props: {
label: string
shape?: 'square' | 'rounded'
removable?: boolean
onRemove?: (...args: unknown[]) => void
},
options?: { slots?: Record<string, string> }
) {
return render(Tag, {
props,
global: { plugins: [i18n] },
...options
})
}
describe('Tag', () => {
it('renders label text', () => {
renderTag({ label: 'JavaScript' })
expect(screen.getByText('JavaScript')).toBeInTheDocument()
})
it('does not show remove button by default', () => {
renderTag({ label: 'Test' })
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('shows remove button when removable', () => {
renderTag({ label: 'Test', removable: true })
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
})
it('emits remove event when remove button is clicked', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
renderTag({ label: 'Test', removable: true, onRemove })
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(onRemove).toHaveBeenCalledOnce()
})
it('renders icon slot content', () => {
renderTag(
{ label: 'LoRA' },
{
slots: {
icon: '<i data-testid="tag-icon" class="icon-[lucide--folder]" />'
}
}
)
expect(screen.getByTestId('tag-icon')).toBeInTheDocument()
})
})

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { tagVariants } from './tag.variants'
import type { TagVariants } from './tag.variants'
const {
label,
shape = 'square',
state = 'default',
removable = false,
class: className
} = defineProps<{
label: string
shape?: TagVariants['shape']
state?: TagVariants['state']
removable?: boolean
class?: string
}>()
const emit = defineEmits<{
remove: [event: Event]
}>()
const tagClass = computed(() =>
cn(tagVariants({ shape, state, removable }), className)
)
</script>
<template>
<span :class="tagClass">
<slot name="icon" />
<span class="truncate">{{ label }}</span>
<button
v-if="removable"
type="button"
:aria-label="$t('g.remove')"
class="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full p-0.5 hover:bg-white/10"
@click.stop="emit('remove', $event)"
>
<i class="icon-[lucide--x] size-3" aria-hidden="true" />
</button>
</span>
</template>

View File

@@ -1,29 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const tagVariants = cva({
base: 'inline-flex h-6 shrink-0 items-center justify-center gap-1 text-xs',
variants: {
shape: {
square: 'rounded-sm bg-modal-card-tag-background',
rounded: 'rounded-full bg-secondary-background',
overlay: 'rounded-sm bg-zinc-500/40 text-white/90'
},
state: {
default: 'text-modal-card-tag-foreground',
unselected: 'text-muted-foreground opacity-70',
selected: 'text-modal-card-tag-foreground'
},
removable: {
true: 'py-1 pr-1 pl-2',
false: 'px-2 py-1'
}
},
defaultVariants: {
shape: 'square',
state: 'default',
removable: false
}
})
export type TagVariants = VariantProps<typeof tagVariants>

View File

@@ -265,11 +265,10 @@
</template>
<template #bottom-right>
<template v-if="template.tags && template.tags.length > 0">
<Tag
<SquareChip
v-for="tag in template.tags"
:key="tag"
:label="tag"
shape="overlay"
/>
</template>
</template>
@@ -403,7 +402,7 @@ import { useI18n } from 'vue-i18n'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'

View File

@@ -1,6 +1,6 @@
<template>
<div
v-show="tooltipText"
v-if="tooltipText"
ref="tooltipRef"
class="node-tooltip"
:style="{ left, top }"
@@ -34,8 +34,7 @@ const left = ref<string>()
const top = ref<string>()
function hideTooltip() {
if (!tooltipText.value) return
tooltipText.value = ''
return (tooltipText.value = '')
}
async function showTooltip(tooltip: string | null | undefined) {

View File

@@ -1,6 +1,6 @@
<template>
<div
v-show="isVisible"
v-if="isVisible"
class="pointer-events-none absolute z-9999 border border-blue-400 bg-blue-500/20"
:style="rectangleStyle"
/>

View File

@@ -99,13 +99,13 @@
</Button>
</template>
<template #bottom-right>
<Tag label="png" shape="overlay" />
<Tag label="1.2 MB" shape="overlay" />
<Tag label="LoRA" shape="overlay">
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<i class="icon-[lucide--folder]" />
</template>
</Tag>
</SquareChip>
</template>
</CardTop>
</template>
@@ -129,7 +129,7 @@ import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'

View File

@@ -6,7 +6,7 @@ import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
@@ -76,7 +76,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
CardContainer,
CardTop,
CardBottom,
Tag
SquareChip
},
setup() {
const t = (k: string) => k
@@ -276,13 +276,13 @@ const createStoryTemplate = (args: StoryArgs) => ({
</Button>
</template>
<template #bottom-right>
<Tag label="png" />
<Tag label="1.2 MB" />
<Tag label="LoRA">
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<i class="icon-[lucide--folder] size-3" />
</template>
</Tag>
</SquareChip>
</template>
</CardTop>
</template>
@@ -392,13 +392,13 @@ const createStoryTemplate = (args: StoryArgs) => ({
</Button>
</template>
<template #bottom-right>
<Tag label="png" />
<Tag label="1.2 MB" />
<Tag label="LoRA">
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<i class="icon-[lucide--folder] size-3" />
</template>
</Tag>
</SquareChip>
</template>
</CardTop>
</template>

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick, reactive } from 'vue'
import type { EffectScope } from 'vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
@@ -87,7 +88,7 @@ describe('useBrowserTabTitle', () => {
})
it('sets default title when idle and no workflow', () => {
const scope = effectScope()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
expect(document.title).toBe('ComfyUI')
scope.stop()
@@ -100,7 +101,7 @@ describe('useBrowserTabTitle', () => {
isModified: false,
isPersisted: true
}
const scope = effectScope()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
@@ -114,7 +115,7 @@ describe('useBrowserTabTitle', () => {
isModified: true,
isPersisted: true
}
const scope = effectScope()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('*myFlow - ComfyUI')
@@ -132,11 +133,9 @@ describe('useBrowserTabTitle', () => {
isModified: true,
isPersisted: true
}
const scope = effectScope()
scope.run(() => useBrowserTabTitle())
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
scope.stop()
})
it('hides asterisk while Shift key is held', async () => {
@@ -151,21 +150,21 @@ describe('useBrowserTabTitle', () => {
isModified: true,
isPersisted: true
}
const scope = effectScope()
scope.run(() => useBrowserTabTitle())
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
scope.stop()
})
it('disables workflow title when menu disabled', async () => {
// Fails when run together with other tests. Suspect to be caused by leaked
// state from previous tests.
it.skip('disables workflow title when menu disabled', async () => {
vi.mocked(settingStore.get).mockReturnValue('Disabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: false,
isPersisted: true
}
const scope = effectScope()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('ComfyUI')
@@ -175,7 +174,7 @@ describe('useBrowserTabTitle', () => {
it('shows execution progress when not idle without workflow', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.3
const scope = effectScope()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('[30%]ComfyUI')
@@ -197,7 +196,7 @@ describe('useBrowserTabTitle', () => {
}
}
}
const scope = effectScope()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('[40%][50%] Foo')
@@ -217,7 +216,7 @@ describe('useBrowserTabTitle', () => {
},
'2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' }
}
const scope = effectScope()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('[40%][2 nodes running]')

View File

@@ -0,0 +1,21 @@
// TODO: Fix these tests after migration
import { describe } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import { dirtyTest } from './__fixtures__/testExtensions'
describe.skip('LGraph configure()', () => {
dirtyTest(
'LGraph matches previous snapshot (normal configure() usage)',
({ expect, minimalSerialisableGraph, basicSerialisableGraph }) => {
const configuredMinGraph = new LGraph()
configuredMinGraph.configure(minimalSerialisableGraph)
expect(configuredMinGraph).toMatchSnapshot('configuredMinGraph')
const configuredBasicGraph = new LGraph()
configuredBasicGraph.configure(basicSerialisableGraph)
expect(configuredBasicGraph).toMatchSnapshot('configuredBasicGraph')
}
)
})

View File

@@ -0,0 +1,19 @@
// TODO: Fix these tests after migration
import { describe } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import { dirtyTest } from './__fixtures__/testExtensions'
describe.skip('LGraph (constructor only)', () => {
dirtyTest(
'Matches previous snapshot',
({ expect, minimalSerialisableGraph, basicSerialisableGraph }) => {
const minLGraph = new LGraph(minimalSerialisableGraph)
expect(minLGraph).toMatchSnapshot('minLGraph')
const basicLGraph = new LGraph(basicSerialisableGraph)
expect(basicLGraph).toMatchSnapshot('basicLGraph')
}
)
})

View File

@@ -39,3 +39,29 @@ export const minimalSerialisableGraph: SerialisableGraph = {
links: [],
groups: []
}
export const basicSerialisableGraph: SerialisableGraph = {
id: 'ca9da7d8-fddd-4707-ad32-67be9be13140',
revision: 0,
version: 1,
config: {},
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
groups: [
{
id: 123,
bounding: [20, 20, 1, 3],
color: '#6029aa',
font_size: 14,
title: 'A group to test with'
}
],
nodes: [
{ id: 1, type: 'mustBeSet' } as Partial<ISerialisedNode> as ISerialisedNode
],
links: []
}

View File

@@ -2,6 +2,7 @@
import { test as baseTest } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/LGraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
ISerialisedGraph,
SerialisableGraph
@@ -11,7 +12,11 @@ import floatingBranch from './assets/floatingBranch.json' with { type: 'json' }
import floatingLink from './assets/floatingLink.json' with { type: 'json' }
import linkedNodes from './assets/linkedNodes.json' with { type: 'json' }
import reroutesComplex from './assets/reroutesComplex.json' with { type: 'json' }
import { minimalSerialisableGraph, oldSchemaGraph } from './assets/testGraphs'
import {
basicSerialisableGraph,
minimalSerialisableGraph,
oldSchemaGraph
} from './assets/testGraphs'
interface LitegraphFixtures {
minimalGraph: LGraph
@@ -23,7 +28,11 @@ interface LitegraphFixtures {
reroutesComplexGraph: LGraph
}
/** LiteGraph test fixtures. Each creates an LGraph from cloned data; LGraph singletons may still share some global state. */
/** These fixtures alter global state, and are difficult to reset. Relies on a single test per-file to reset state. */
interface DirtyFixtures {
basicSerialisableGraph: SerialisableGraph
}
export const test = baseTest.extend<LitegraphFixtures>({
minimalGraph: async ({}, use) => {
// Before each test function
@@ -56,3 +65,17 @@ export const test = baseTest.extend<LitegraphFixtures>({
await use(graph)
}
})
/** Test that use {@link DirtyFixtures}. One test per file. */
export const dirtyTest = test.extend<DirtyFixtures>({
basicSerialisableGraph: async ({}, use) => {
if (!basicSerialisableGraph.nodes) throw new Error('Invalid test object')
// Register node types
for (const node of basicSerialisableGraph.nodes) {
LiteGraph.registerNodeType(node.type!, LiteGraph.LGraphNode)
}
await use(structuredClone(basicSerialisableGraph))
}
})

View File

@@ -1,7 +1,9 @@
// TODO: Fix these tests after migration
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LinkConnector } from '@/lib/litegraph/src/litegraph'
import {
createMockCanvasPointerEvent,
createMockLGraphNode,
createMockLinkNetwork,
createMockNodeInputSlot,
@@ -40,7 +42,7 @@ function mockRenderLinkImpl(canConnect: boolean): RenderLinkItem {
const mockNode = createMockLGraphNode()
const mockInput = createMockNodeInputSlot()
describe('LinkConnector', () => {
describe.skip('LinkConnector', () => {
let connector: LinkConnector
beforeEach(() => {
@@ -50,7 +52,7 @@ describe('LinkConnector', () => {
vi.clearAllMocks()
})
describe('isInputValidDrop', () => {
describe.skip('isInputValidDrop', () => {
test('should return false if there are no render links', () => {
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(false)
})
@@ -72,5 +74,110 @@ describe('LinkConnector', () => {
expect(link1.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
expect(link2.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
})
test('should call canConnectToInput on each render link until one returns true', () => {
const link1 = mockRenderLinkImpl(false)
const link2 = mockRenderLinkImpl(true) // This one can connect
const link3 = mockRenderLinkImpl(false)
connector.renderLinks.push(link1, link2, link3)
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(true)
expect(link1.canConnectToInput).toHaveBeenCalledTimes(1)
expect(link2.canConnectToInput).toHaveBeenCalledTimes(1) // Stops here
expect(link3.canConnectToInput).not.toHaveBeenCalled() // Should not be called
})
})
describe.skip('listenUntilReset', () => {
test('should add listener for the specified event and for reset', () => {
const listener = vi.fn()
const addEventListenerSpy = vi.spyOn(connector.events, 'addEventListener')
connector.listenUntilReset('before-drop-links', listener)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'before-drop-links',
listener,
undefined
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'reset',
expect.any(Function),
{ once: true }
)
})
test('should call the listener when the event is dispatched before reset', () => {
const listener = vi.fn()
const eventData = {
renderLinks: [],
event: createMockCanvasPointerEvent(0, 0)
}
connector.listenUntilReset('before-drop-links', listener)
connector.events.dispatch('before-drop-links', eventData)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(
new CustomEvent('before-drop-links')
)
})
test('should remove the listener when reset is dispatched', () => {
const listener = vi.fn()
const removeEventListenerSpy = vi.spyOn(
connector.events,
'removeEventListener'
)
connector.listenUntilReset('before-drop-links', listener)
// Simulate the reset event being dispatched
connector.events.dispatch('reset', false)
// Check if removeEventListener was called correctly for the original listener
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'before-drop-links',
listener
)
})
test('should not call the listener after reset is dispatched', () => {
const listener = vi.fn()
const eventData = {
renderLinks: [],
event: createMockCanvasPointerEvent(0, 0)
}
connector.listenUntilReset('before-drop-links', listener)
// Dispatch reset first
connector.events.dispatch('reset', false)
// Then dispatch the original event
connector.events.dispatch('before-drop-links', eventData)
expect(listener).not.toHaveBeenCalled()
})
test('should pass options to addEventListener', () => {
const listener = vi.fn()
const options = { once: true }
const addEventListenerSpy = vi.spyOn(connector.events, 'addEventListener')
connector.listenUntilReset('after-drop-links', listener, options)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'after-drop-links',
listener,
options
)
// Still adds the reset listener
expect(addEventListenerSpy).toHaveBeenCalledWith(
'reset',
expect.any(Function),
{ once: true }
)
})
})
})

View File

@@ -25,34 +25,3 @@ export function warnDeprecated(message: string, source?: object): void {
callback(message, source)
}
}
/**
* Defines a deprecated property alias that proxies to a current property,
* logging a deprecation warning on first access.
*
* Warning is deduplicated by {@link warnDeprecated} (once per unique message per session).
*
* @param target The object to define the deprecated property on.
* @param deprecatedKey The old property name to deprecate.
* @param currentKey The new property name to proxy to.
* @param message The deprecation warning message.
*/
export function defineDeprecatedProperty<T>(
target: T,
deprecatedKey: string,
currentKey: keyof T & string,
message: string
): void {
Object.defineProperty(target, deprecatedKey, {
get() {
warnDeprecated(message)
return this[currentKey]
},
set(value: unknown) {
warnDeprecated(message)
this[currentKey] = value
},
configurable: true,
enumerable: false
})
}

View File

@@ -2358,7 +2358,6 @@
"tierNameYearly": "{name} Yearly",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
"refreshCredits": "Refresh credits",
"benefits": {
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
"benefit1FreeTier": "More monthly credits, top up anytime",

View File

@@ -126,7 +126,6 @@ const i18n = createI18n({
viewMoreDetailsPlans: 'View more details about plans & pricing',
learnMore: 'Learn More',
messageSupport: 'Message Support',
refreshCredits: 'Refresh credits',
invoiceHistory: 'Invoice History',
partnerNodesCredits: 'Partner nodes pricing',
renewsDate: 'Renews {date}',
@@ -201,8 +200,8 @@ function createWrapper(overrides = {}) {
SubscriptionBenefits: true,
Button: {
template:
'<button v-bind="$attrs" @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon"><slot/></button>',
props: ['variant', 'size', 'loading', 'label', 'icon'],
'<button @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon"><slot/></button>',
props: ['variant', 'size'],
emits: ['click']
},
Skeleton: {
@@ -214,17 +213,6 @@ function createWrapper(overrides = {}) {
})
}
function findButtonByText(
wrapper: ReturnType<typeof createWrapper>,
text: string
) {
const button = wrapper
.findAll('button')
.find((button) => button.text().includes(text))
if (!button) throw new Error(`Button with text "${text}" not found`)
return button
}
describe('SubscriptionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -233,8 +221,6 @@ describe('SubscriptionPanel', () => {
mockIsCancelled.value = false
mockSubscriptionTier.value = 'CREATOR'
mockIsYearlySubscription.value = false
mockCreditsData.isLoadingBalance = false
mockActionsData.isLoadingSupport = false
})
describe('subscription state functionality', () => {
@@ -309,7 +295,7 @@ describe('SubscriptionPanel', () => {
mockIsActiveSubscription.value = true
const wrapper = createWrapper()
expect(wrapper.text()).toMatch(/Included \(Refills \d{2}\/\d{2}\/\d{2}\)/)
expect(wrapper.text()).toContain('Included (Refills 12/31/24)')
expect(wrapper.text()).not.toContain('&#x2F;')
vi.useRealTimers()
@@ -317,41 +303,43 @@ describe('SubscriptionPanel', () => {
})
})
describe('action buttons', () => {
// TODO: Re-enable when migrating to VTL so we can find by user visible content.
describe.skip('action buttons', () => {
it('should call handleLearnMoreClick when learn more is clicked', async () => {
const wrapper = createWrapper()
const learnMoreButton = findButtonByText(wrapper, 'Learn More')
const learnMoreButton = wrapper.find('[data-testid="Learn More"]')
await learnMoreButton.trigger('click')
expect(mockActionsData.handleLearnMoreClick).toHaveBeenCalledOnce()
})
it('should call handleMessageSupport when message support is clicked', async () => {
const wrapper = createWrapper()
const supportButton = findButtonByText(wrapper, 'Message Support')
const supportButton = wrapper.find('[data-testid="Message Support"]')
await supportButton.trigger('click')
expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
})
it('should call handleRefresh when refresh button is clicked', async () => {
const wrapper = createWrapper()
const refreshButton = wrapper.find('button[aria-label="Refresh credits"]')
// Find the refresh button by icon
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
await refreshButton.trigger('click')
expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
})
})
describe('loading states', () => {
describe.skip('loading states', () => {
it('should show loading state on support button when loading', () => {
mockActionsData.isLoadingSupport = true
const wrapper = createWrapper()
const supportButton = findButtonByText(wrapper, 'Message Support')
const supportButton = wrapper.find('[data-testid="Message Support"]')
expect(supportButton.attributes('disabled')).toBeDefined()
})
it('should show loading state on refresh button when loading balance', () => {
mockCreditsData.isLoadingBalance = true
const wrapper = createWrapper()
const refreshButton = wrapper.find('button[aria-label="Refresh credits"]')
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
expect(refreshButton.attributes('disabled')).toBeDefined()
})
})

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