Compare commits
13 Commits
test/prior
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99a7fc8c87 | ||
|
|
ff44443b5e | ||
|
|
407375af07 | ||
|
|
65f18d17af | ||
|
|
54a00aac75 | ||
|
|
d2358c83e8 | ||
|
|
b2f848893a | ||
|
|
5c0e15f403 | ||
|
|
dc09eb60e4 | ||
|
|
30b17407db | ||
|
|
5b4ebf4d99 | ||
|
|
6836419e96 | ||
|
|
4c59a5e424 |
84
.claude/skills/adding-deprecation-warnings/SKILL.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
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
|
||||
1
apps/website/public/icons/social/discord.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
apps/website/public/icons/social/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 819 B |
1
apps/website/public/icons/social/instagram.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 988 B |
1
apps/website/public/icons/social/linkedin.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 536 B |
1
apps/website/public/icons/social/reddit.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 915 B |
1
apps/website/public/icons/social/x.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 254 B |
143
apps/website/src/components/SiteFooter.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<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">
|
||||
© {{ 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>
|
||||
149
apps/website/src/components/SiteNav.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<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>
|
||||
86
apps/website/src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
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>
|
||||
@@ -5,5 +5,5 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "astro.config.mjs"]
|
||||
"include": ["src/**/*", "astro.config.ts"]
|
||||
}
|
||||
|
||||
@@ -30,6 +30,24 @@ 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 |
|
||||
|
||||
@@ -3,17 +3,28 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
import { BuilderFooterHelper } from './BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from './BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from './BuilderStepsHelper'
|
||||
|
||||
export class AppModeHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
private get builderToolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
|
||||
async enterBuilder() {
|
||||
await this.page
|
||||
@@ -24,42 +35,6 @@ export class AppModeHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Exit builder mode via the footer "Exit app builder" button. */
|
||||
async exitBuilder() {
|
||||
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Inputs" step in the builder toolbar. */
|
||||
async goToInputs() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Outputs" step in the builder toolbar. */
|
||||
async goToOutputs() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Preview" step in the builder toolbar. */
|
||||
async goToPreview() {
|
||||
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Next" button in the builder footer. */
|
||||
async next() {
|
||||
await this.page.getByRole('button', { name: 'Next' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the "Back" button in the builder footer. */
|
||||
async back() {
|
||||
await this.page.getByRole('button', { name: 'Back' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Toggle app mode (linear view) on/off. */
|
||||
async toggleAppMode() {
|
||||
await this.page.evaluate(() => {
|
||||
@@ -118,107 +93,4 @@ export class AppModeHelper {
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the builder input-select
|
||||
* sidebar (IoItem).
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getBuilderInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
.filter({ hasText: title })
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the builder preview/arrange
|
||||
* sidebar (AppModeWidgetList with builderMode).
|
||||
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
|
||||
*/
|
||||
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
|
||||
return this.page
|
||||
.locator(`[aria-label="${ariaLabel}"]`)
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/** The builder footer nav containing save/navigation buttons. */
|
||||
private get builderFooterNav(): Locator {
|
||||
return this.page
|
||||
.getByRole('button', { name: 'Exit app builder' })
|
||||
.locator('..')
|
||||
}
|
||||
|
||||
/** Get a button in the builder footer by its accessible name. */
|
||||
getFooterButton(name: string | RegExp): Locator {
|
||||
return this.builderFooterNav.getByRole('button', { name })
|
||||
}
|
||||
|
||||
/** Click the save/save-as button in the builder footer. */
|
||||
async clickSave() {
|
||||
await this.getFooterButton(/^Save/).first().click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** The "Opens as" popover tab above the builder footer. */
|
||||
get opensAsPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget by clicking its popover trigger, selecting "Rename",
|
||||
* and filling in the dialog.
|
||||
* @param popoverTrigger The button that opens the widget's actions popover.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameWidget(popoverTrigger: Locator, newName: string) {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getBuilderInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title to trigger
|
||||
* inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.filter({ hasText: title })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
69
browser_tests/fixtures/helpers/BuilderFooterHelper.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class BuilderFooterHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get nav(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.footerNav)
|
||||
}
|
||||
|
||||
get exitButton(): Locator {
|
||||
return this.buttonByName('Exit app builder')
|
||||
}
|
||||
|
||||
get nextButton(): Locator {
|
||||
return this.buttonByName('Next')
|
||||
}
|
||||
|
||||
get backButton(): Locator {
|
||||
return this.buttonByName('Back')
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveButton)
|
||||
}
|
||||
|
||||
get saveAsButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
}
|
||||
|
||||
get saveAsChevron(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsChevron)
|
||||
}
|
||||
|
||||
get opensAsPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
private buttonByName(name: string): Locator {
|
||||
return this.nav.getByRole('button', { name })
|
||||
}
|
||||
|
||||
async next() {
|
||||
await this.nextButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async back() {
|
||||
await this.backButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async exitBuilder() {
|
||||
await this.exitButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async openSaveAsFromChevron() {
|
||||
await this.saveAsChevron.click()
|
||||
await this.page.getByRole('menuitem', { name: 'Save as' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
78
browser_tests/fixtures/helpers/BuilderSaveAsHelper.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class BuilderSaveAsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
/** The save-as dialog (scoped by aria-labelledby). */
|
||||
get dialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save"]')
|
||||
}
|
||||
|
||||
/** The post-save success dialog (scoped by aria-labelledby). */
|
||||
get successDialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save-success"]')
|
||||
}
|
||||
|
||||
get title(): Locator {
|
||||
return this.dialog.getByText('Save as')
|
||||
}
|
||||
|
||||
get radioGroup(): Locator {
|
||||
return this.dialog.getByRole('radiogroup')
|
||||
}
|
||||
|
||||
get nameInput(): Locator {
|
||||
return this.dialog.getByRole('textbox')
|
||||
}
|
||||
|
||||
viewTypeRadio(viewType: 'App' | 'Node graph'): Locator {
|
||||
return this.dialog.getByRole('radio', { name: viewType })
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.dialog.getByRole('button', { name: 'Save' })
|
||||
}
|
||||
|
||||
get successMessage(): Locator {
|
||||
return this.successDialog.getByText('Successfully saved')
|
||||
}
|
||||
|
||||
get viewAppButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'View app' })
|
||||
}
|
||||
|
||||
get closeButton(): Locator {
|
||||
return this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
}
|
||||
|
||||
/** The X button to dismiss the success dialog without any action. */
|
||||
get dismissButton(): Locator {
|
||||
return this.successDialog.locator('button.p-dialog-close-button')
|
||||
}
|
||||
|
||||
get exitBuilderButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'Exit builder' })
|
||||
}
|
||||
|
||||
get overwriteDialog(): Locator {
|
||||
return this.page.getByRole('dialog', { name: 'Overwrite existing file?' })
|
||||
}
|
||||
|
||||
get overwriteButton(): Locator {
|
||||
return this.overwriteDialog.getByRole('button', { name: 'Overwrite' })
|
||||
}
|
||||
|
||||
async fillAndSave(workflowName: string, viewType: 'App' | 'Node graph') {
|
||||
await this.nameInput.fill(workflowName)
|
||||
await this.viewTypeRadio(viewType).click()
|
||||
await this.saveButton.click()
|
||||
}
|
||||
}
|
||||
139
browser_tests/fixtures/helpers/BuilderSelectHelper.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a builder IoItem (input-select sidebar).
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
.filter({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
})
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the preview/arrange sidebar.
|
||||
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
|
||||
*/
|
||||
getPreviewWidgetMenu(ariaLabel: string): Locator {
|
||||
return this.page
|
||||
.getByLabel(ariaLabel, { exact: true })
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/** Delete a builder input via its actions menu. */
|
||||
async deleteInput(title: string) {
|
||||
const menu = this.getInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Delete', { exact: true }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title for inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget via its actions popover (works in preview and app mode).
|
||||
* @param popoverTrigger The button that opens the widget's actions popover.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameWidget(popoverTrigger: Locator, newName: string) {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async selectInputWidget(node: NodeReference) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
await node.centerOnNode()
|
||||
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await this.page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async selectOutputNode() {
|
||||
const saveImageNodeId = await this.page.evaluate(() => {
|
||||
const node = window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
return node ? String(node.id) : null
|
||||
})
|
||||
if (!saveImageNodeId)
|
||||
throw new Error('SaveImage/PreviewImage node not found')
|
||||
const saveImageRef =
|
||||
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await this.page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
30
browser_tests/fixtures/helpers/BuilderStepsHelper.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class BuilderStepsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get toolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
async goToInputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToOutputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToPreview() {
|
||||
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,39 @@ 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,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { ConsoleMessage, Locator, 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'
|
||||
@@ -413,4 +414,138 @@ 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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import type { AppMode } from '../../../src/composables/useAppMode'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
@@ -104,6 +105,40 @@ export class WorkflowHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowPath(): Promise<string | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.path
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowActiveAppMode(): Promise<AppMode | null | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.activeMode
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowInitialMode(): Promise<AppMode | null | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.initialMode
|
||||
})
|
||||
}
|
||||
|
||||
async getLinearModeFromGraph(): Promise<boolean | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return window.app!.rootGraph.extra?.linearMode as boolean | undefined
|
||||
})
|
||||
}
|
||||
|
||||
async getOpenWorkflowCount(): Promise<number> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow.workflows
|
||||
.length
|
||||
})
|
||||
}
|
||||
|
||||
async isCurrentWorkflowModified(): Promise<boolean | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
|
||||
@@ -77,6 +77,10 @@ export const TestIds = {
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
builder: {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
saveAsButton: 'builder-save-as-button',
|
||||
saveAsChevron: 'builder-save-as-chevron',
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
|
||||
@@ -392,6 +392,11 @@ 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,
|
||||
|
||||
@@ -6,46 +6,6 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
import { fitToViewInstant } from './fitToView'
|
||||
import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async function selectOutputNode(comfyPage: ComfyPage) {
|
||||
const { page } = comfyPage
|
||||
|
||||
const saveImageNodeId = await page.evaluate(() =>
|
||||
String(
|
||||
window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)?.id
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async function selectInputWidget(comfyPage: ComfyPage, node: NodeReference) {
|
||||
const { page } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await node.centerOnNode()
|
||||
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter builder on the default workflow and select I/O.
|
||||
*
|
||||
@@ -70,11 +30,11 @@ export async function setupBuilder(
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
await selectInputWidget(comfyPage, inputNode)
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.selectInputWidget(inputNode)
|
||||
|
||||
await appMode.goToOutputs()
|
||||
await selectOutputNode(comfyPage)
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode()
|
||||
|
||||
return inputNode
|
||||
}
|
||||
|
||||
@@ -12,6 +12,38 @@ 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`
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -29,14 +29,14 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Go back to inputs step where IoItems are shown
|
||||
await appMode.goToInputs()
|
||||
await appMode.steps.goToInputs()
|
||||
|
||||
const menu = appMode.getBuilderInputItemMenu('seed')
|
||||
const menu = appMode.select.getInputItemMenu('seed')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
|
||||
await appMode.select.renameInputViaMenu('seed', 'Builder Input Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
await appMode.footer.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-menu`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
@@ -52,11 +52,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToInputs()
|
||||
await appMode.steps.goToInputs()
|
||||
|
||||
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
|
||||
await appMode.select.renameInput('seed', 'Dblclick Seed')
|
||||
|
||||
await appMode.exitBuilder()
|
||||
await appMode.footer.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-dblclick`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
@@ -68,14 +68,14 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToPreview()
|
||||
await appMode.steps.goToPreview()
|
||||
|
||||
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
|
||||
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameWidget(menu, 'Preview Seed')
|
||||
await appMode.select.renameWidget(menu, 'Preview Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
await appMode.footer.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-preview`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
@@ -88,13 +88,13 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Enter app mode from builder
|
||||
await appMode.exitBuilder()
|
||||
await appMode.footer.exitBuilder()
|
||||
await appMode.toggleAppMode()
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const menu = appMode.getAppModeWidgetMenu('seed')
|
||||
await appMode.renameWidget(menu, 'App Mode Seed')
|
||||
await appMode.select.renameWidget(menu, 'App Mode Seed')
|
||||
|
||||
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
|
||||
|
||||
|
||||
@@ -19,24 +19,26 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
.toBe(`*${workflowName} - ComfyUI`)
|
||||
})
|
||||
|
||||
// Failing on CI
|
||||
// Cannot reproduce locally
|
||||
test.skip('Can display workflow name with unsaved changes', async ({
|
||||
test('Can display workflow name with unsaved changes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const workflowName = await comfyPage.page.evaluate(async () => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.filename
|
||||
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()
|
||||
})
|
||||
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`)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.title())
|
||||
.toBe(`*${workflowName} - ComfyUI`)
|
||||
|
||||
// Delete the saved workflow for cleanup.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
|
||||
@@ -2,10 +2,60 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { setupSubgraphBuilder } from '../helpers/builderTestUtils'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import { setupBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
/**
|
||||
* Open the save-as dialog, fill name + view type, click save,
|
||||
* and wait for the success dialog.
|
||||
*/
|
||||
async function builderSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a different workflow, then reopen the named one from the sidebar.
|
||||
* Caller must ensure the page is in graph mode (not builder or app mode)
|
||||
* before calling.
|
||||
*/
|
||||
async function openWorkflowFromSidebar(comfyPage: ComfyPage, name: string) {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(name).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain(name)
|
||||
}).toPass({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* After a first save, open save-as again from the chevron,
|
||||
* fill name + view type, and save.
|
||||
*/
|
||||
async function reSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph'
|
||||
) {
|
||||
await appMode.footer.openSaveAsFromChevron()
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
}
|
||||
|
||||
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
@@ -21,231 +71,301 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
})
|
||||
|
||||
test('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
const { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
// The save-as dialog should appear with filename input and view type selection
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(dialog.getByRole('textbox')).toBeVisible()
|
||||
await expect(dialog.getByText('Save as')).toBeVisible()
|
||||
|
||||
// View type radio group should be present
|
||||
const radioGroup = dialog.getByRole('radiogroup')
|
||||
await expect(radioGroup).toBeVisible()
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.nameInput).toBeVisible()
|
||||
await expect(saveAs.title).toBeVisible()
|
||||
await expect(saveAs.radioGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save as dialog allows entering filename and saving', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const workflowName = `${Date.now()} builder-save-test`
|
||||
const input = dialog.getByRole('textbox')
|
||||
await input.fill(workflowName)
|
||||
|
||||
// Save button should be enabled now
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeEnabled()
|
||||
await saveButton.click()
|
||||
|
||||
// Success dialog should appear
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} builder-save`, 'App')
|
||||
})
|
||||
|
||||
test('Save as dialog disables save when filename is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
const { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
await saveAs.nameInput.fill('')
|
||||
await expect(saveAs.saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
// Clear the filename input
|
||||
const input = dialog.getByRole('textbox')
|
||||
await input.fill('')
|
||||
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
|
||||
const { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
// Save button should be disabled
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeDisabled()
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const appRadio = saveAs.viewTypeRadio('App')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
const graphRadio = saveAs.viewTypeRadio('Node graph')
|
||||
await graphRadio.click()
|
||||
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
test('Builder step navigation works correctly', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
const { footer } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
// Should start at outputs (we ended there in setup)
|
||||
// Navigate to inputs
|
||||
await appMode.goToInputs()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
// Back button should be disabled on first step
|
||||
const backButton = appMode.getFooterButton('Back')
|
||||
await expect(backButton).toBeDisabled()
|
||||
await expect(footer.backButton).toBeDisabled()
|
||||
await expect(footer.nextButton).toBeEnabled()
|
||||
|
||||
// Next button should be enabled
|
||||
const nextButton = appMode.getFooterButton('Next')
|
||||
await expect(nextButton).toBeEnabled()
|
||||
await footer.next()
|
||||
await expect(footer.backButton).toBeEnabled()
|
||||
|
||||
// Navigate forward
|
||||
await appMode.next()
|
||||
|
||||
// Back button should now be enabled
|
||||
await expect(backButton).toBeEnabled()
|
||||
|
||||
// Navigate to preview (last step)
|
||||
await appMode.next()
|
||||
|
||||
// Next button should be disabled on last step
|
||||
await expect(nextButton).toBeDisabled()
|
||||
await footer.next()
|
||||
await expect(footer.nextButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Escape key exits builder mode', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
// Verify builder toolbar is visible
|
||||
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
|
||||
await expect(toolbar).toBeVisible()
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||
|
||||
// Press Escape
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Builder toolbar should be gone
|
||||
await expect(toolbar).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
|
||||
await expect(toolbar).toBeVisible()
|
||||
|
||||
await appMode.exitBuilder()
|
||||
|
||||
await expect(toolbar).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||
await comfyPage.appMode.footer.exitBuilder()
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Save button directly saves for previously saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
const { footer, saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
// First save via builder save-as to make it non-temporary
|
||||
await appMode.clickSave()
|
||||
const saveAsDialog = page.getByRole('dialog')
|
||||
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
|
||||
const workflowName = `${Date.now()} builder-direct-save`
|
||||
await saveAsDialog.getByRole('textbox').fill(workflowName)
|
||||
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Dismiss the success dialog
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await successDialog.getByText('Close', { exact: true }).click()
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} direct-save`, 'App')
|
||||
await saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now click save again — should save directly
|
||||
await appMode.clickSave()
|
||||
// Modify the workflow so the save button becomes enabled
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await comfyPage.appMode.select.deleteInput('seed')
|
||||
await expect(footer.saveButton).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 })
|
||||
await expect(appMode.getFooterButton(/^Save$/)).toBeDisabled()
|
||||
await footer.saveButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(saveAs.dialog).not.toBeVisible({ timeout: 2000 })
|
||||
await expect(footer.saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Split button chevron opens save-as for saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
const { footer, saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
// First save via builder save-as to make it non-temporary
|
||||
await appMode.clickSave()
|
||||
const saveAsDialog = page.getByRole('dialog')
|
||||
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
|
||||
const workflowName = `${Date.now()} builder-split-btn`
|
||||
await saveAsDialog.getByRole('textbox').fill(workflowName)
|
||||
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Dismiss the success dialog
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await successDialog.getByText('Close', { exact: true }).click()
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} split-btn`, 'App')
|
||||
await saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Click the chevron dropdown trigger
|
||||
const chevronButton = appMode.getFooterButton('Save as')
|
||||
await chevronButton.click()
|
||||
await footer.openSaveAsFromChevron()
|
||||
|
||||
// "Save as" menu item should appear
|
||||
const menuItem = page.getByRole('menuitem', { name: 'Save as' })
|
||||
await expect(menuItem).toBeVisible({ timeout: 5000 })
|
||||
await menuItem.click()
|
||||
|
||||
// Save-as dialog should appear
|
||||
const newSaveAsDialog = page.getByRole('dialog')
|
||||
await expect(newSaveAsDialog.getByText('Save as')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(newSaveAsDialog.getByRole('textbox')).toBeVisible()
|
||||
await expect(saveAs.title).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.nameInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Connect output popover appears when no outputs selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
|
||||
// Without selecting any outputs, click the save button
|
||||
// It should trigger the connect-output popover
|
||||
await appMode.clickSave()
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
// The popover should show a message about connecting outputs
|
||||
await expect(
|
||||
page.getByText('Connect an output', { exact: false })
|
||||
comfyPage.page.getByText('Connect an output', { exact: false })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
test('save as app produces correct extension and linearMode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-ext`, 'App')
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain('.app.json')
|
||||
|
||||
// App should be selected by default
|
||||
const appRadio = dialog.getByRole('radio', { name: /App/ })
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(true)
|
||||
})
|
||||
|
||||
// Click Node graph option
|
||||
const graphRadio = dialog.getByRole('radio', { name: /Node graph/ })
|
||||
await graphRadio.click()
|
||||
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
|
||||
test('save as node graph produces correct extension and linearMode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(
|
||||
comfyPage.appMode,
|
||||
`${Date.now()} graph-ext`,
|
||||
'Node graph'
|
||||
)
|
||||
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toMatch(/\.json$/)
|
||||
expect(path).not.toContain('.app.json')
|
||||
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(false)
|
||||
})
|
||||
|
||||
test('save as app View App button enters app mode', async ({ comfyPage }) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
|
||||
|
||||
await comfyPage.appMode.saveAs.viewAppButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.workflow.getActiveWorkflowActiveAppMode()).toBe(
|
||||
'app'
|
||||
)
|
||||
})
|
||||
|
||||
test('save as node graph Exit builder exits builder mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(
|
||||
comfyPage.appMode,
|
||||
`${Date.now()} graph-exit`,
|
||||
'Node graph'
|
||||
)
|
||||
|
||||
await comfyPage.appMode.saveAs.exitBuilderButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('save as with different mode does not modify the original workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
const originalName = `${Date.now()} original`
|
||||
await builderSaveAs(appMode, originalName, 'App')
|
||||
const originalPath = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(originalPath).toContain('.app.json')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Re-save as node graph — creates a copy
|
||||
await reSaveAs(appMode, `${Date.now()} copy`, 'Node graph')
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const newPath = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(newPath).not.toBe(originalPath)
|
||||
expect(newPath).not.toContain('.app.json')
|
||||
|
||||
// Dismiss success dialog, exit app mode, reopen the original
|
||||
await appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await appMode.toggleAppMode()
|
||||
await openWorkflowFromSidebar(comfyPage, originalName)
|
||||
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(true)
|
||||
})
|
||||
|
||||
test('save as with same name and same mode overwrites in place', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
const name = `${Date.now()} overwrite`
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(appMode, name, 'App')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
|
||||
await reSaveAs(appMode, name, 'App')
|
||||
|
||||
await expect(appMode.saveAs.overwriteDialog).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.overwriteButton.click()
|
||||
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterSecond).toBe(pathAfterFirst)
|
||||
})
|
||||
|
||||
test('save as with same name but different mode creates a new file', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
const name = `${Date.now()} mode-change`
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(appMode, name, 'App')
|
||||
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterFirst).toContain('.app.json')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await reSaveAs(appMode, name, 'Node graph')
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterSecond).not.toBe(pathAfterFirst)
|
||||
expect(pathAfterSecond).toMatch(/\.json$/)
|
||||
expect(pathAfterSecond).not.toContain('.app.json')
|
||||
})
|
||||
|
||||
test('save as app workflow reloads in app mode', async ({ comfyPage }) => {
|
||||
const name = `${Date.now()} reload-app`
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, name, 'App')
|
||||
await comfyPage.appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.appMode.footer.exitBuilder()
|
||||
|
||||
await openWorkflowFromSidebar(comfyPage, name)
|
||||
|
||||
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
|
||||
expect(mode).toBe('app')
|
||||
})
|
||||
|
||||
test('save as node graph workflow reloads in node graph mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const name = `${Date.now()} reload-graph`
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, name, 'Node graph')
|
||||
await comfyPage.appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await openWorkflowFromSidebar(comfyPage, name)
|
||||
|
||||
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
|
||||
expect(mode).toBe('graph')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -256,27 +256,6 @@ 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', () => {
|
||||
|
||||
@@ -55,46 +55,4 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,13 +94,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.click()
|
||||
})
|
||||
})
|
||||
// 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(
|
||||
test(
|
||||
'Can be added to canvas using search',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
@@ -108,7 +102,16 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
|
||||
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 expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-node-copy-added-from-search.png'
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 71 KiB |
@@ -175,7 +175,9 @@ 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')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png', {
|
||||
maxDiffPixels: 50
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
|
||||
@@ -220,10 +222,7 @@ test.describe('Node Interaction', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('moved-link.png')
|
||||
})
|
||||
|
||||
// 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
|
||||
}) => {
|
||||
test('Can copy link by shift-drag existing link', async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
|
||||
DefaultGraphPositions.emptySpace
|
||||
@@ -815,11 +814,15 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
|
||||
|
||||
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
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(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 91 KiB |
@@ -68,7 +68,7 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme('Load workflow from URL dropped onto Vue node', async ({
|
||||
test('Load workflow from URL dropped onto Vue node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fakeUrl = 'https://example.com/workflow.png'
|
||||
|
||||
@@ -481,6 +481,7 @@ 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(
|
||||
|
||||
@@ -176,40 +176,13 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
await expectFilterChips(comfyPage, ['MODEL'])
|
||||
})
|
||||
|
||||
// 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 ({
|
||||
test('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' })
|
||||
const panelBounds = await panel.header.boundingBox()
|
||||
await comfyPage.page.mouse.click(panelBounds!.x - 10, panelBounds!.y - 10)
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
// Verify the filter selection panel is hidden
|
||||
await expect(panel.header).not.toBeVisible()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { recordMeasurement } from '../helpers/perfReporter'
|
||||
import { logMeasurement, recordMeasurement } from '../helpers/perfReporter'
|
||||
|
||||
test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
test('canvas idle style recalculations', async ({ comfyPage }) => {
|
||||
@@ -186,6 +186,22 @@ 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
|
||||
}) => {
|
||||
|
||||
@@ -271,9 +271,11 @@ test.describe('Workflows sidebar', () => {
|
||||
'.comfyui-workflows-open .close-workflow-button'
|
||||
)
|
||||
await closeButton.click()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow'
|
||||
])
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames(), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toEqual(['*Unsaved Workflow'])
|
||||
})
|
||||
|
||||
test('Can close saved workflow with command', async ({ comfyPage }) => {
|
||||
|
||||
@@ -110,16 +110,11 @@ test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const isInSubgraph = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.app!.canvas.graph?.isRootGraph === false
|
||||
)
|
||||
|
||||
expect(await isInSubgraph()).toBe(true)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph()).toBe(false)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget-input slot position',
|
||||
@@ -18,7 +18,10 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await getTextSlotPosition(comfyPage.page, '11')
|
||||
const result = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
@@ -37,7 +40,10 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await getTextSlotPosition(comfyPage.page, '11')
|
||||
const before = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
@@ -73,7 +79,10 @@ test.describe(
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await getTextSlotPosition(comfyPage.page, '11')
|
||||
const after = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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(
|
||||
@@ -14,13 +15,8 @@ test.describe(
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
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()
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
@@ -43,15 +39,7 @@ test.describe(
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
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)
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -25,11 +25,7 @@ test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial slot label
|
||||
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
|
||||
})
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
if (initialInputLabel === null) {
|
||||
throw new Error(
|
||||
@@ -106,11 +102,7 @@ test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the second rename worked
|
||||
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
|
||||
})
|
||||
const afterSecondRename = await comfyPage.subgraph.getSlotLabel('input')
|
||||
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
|
||||
})
|
||||
|
||||
@@ -123,11 +115,7 @@ test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial output slot label
|
||||
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
|
||||
})
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
if (initialOutputLabel === null) {
|
||||
throw new Error(
|
||||
|
||||
@@ -26,38 +26,6 @@ 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')
|
||||
@@ -65,7 +33,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
@@ -74,7 +42,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
@@ -84,7 +52,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
@@ -93,7 +61,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
@@ -103,18 +71,16 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.subgraph.removeSlot('input')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('input')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
@@ -124,18 +90,16 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
const initialCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.rightClickOutputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.subgraph.removeSlot('output')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
const finalCount = await comfyPage.subgraph.getSlotCount('output')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
@@ -145,11 +109,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
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
|
||||
})
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
@@ -165,11 +125,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
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
|
||||
})
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
@@ -181,11 +137,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
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
|
||||
})
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
await comfyPage.subgraph.doubleClickInputSlot(initialInputLabel!)
|
||||
|
||||
@@ -199,11 +151,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
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
|
||||
})
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
@@ -215,11 +163,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
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
|
||||
})
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
await comfyPage.subgraph.doubleClickOutputSlot(initialOutputLabel!)
|
||||
|
||||
@@ -234,11 +178,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
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
|
||||
})
|
||||
const newOutputName = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
expect(newOutputName).toBe(renamedOutputName)
|
||||
expect(newOutputName).not.toBe(initialOutputLabel)
|
||||
@@ -252,11 +192,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
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
|
||||
})
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
// Test that right-click still works for renaming
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel!)
|
||||
@@ -274,11 +210,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
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
|
||||
})
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(rightClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
@@ -292,11 +224,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
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
|
||||
})
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
// Use direct pointer event approach to double-click on label
|
||||
await comfyPage.page.evaluate(() => {
|
||||
@@ -354,11 +282,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
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
|
||||
})
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(labelClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
@@ -430,7 +354,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
expect(subgraphNodes.length).toBe(1)
|
||||
|
||||
const finalNodeCount = await getGraphNodeCount(comfyPage)
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
@@ -440,13 +364,11 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
const initialNodeCount = await getGraphNodeCount(comfyPage)
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalNodeCount = await getGraphNodeCount(comfyPage)
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
|
||||
const deletedNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
@@ -523,7 +445,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialNodeCount = await getGraphNodeCount(comfyPage)
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.canvas.graph!.nodes
|
||||
@@ -544,7 +466,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.page.keyboard.press('Control+v')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalNodeCount = await getGraphNodeCount(comfyPage)
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
@@ -560,20 +482,20 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Get initial node count
|
||||
const initialCount = await getGraphNodeCount(comfyPage)
|
||||
const initialCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
// Undo
|
||||
await comfyPage.keyboard.undo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterUndoCount = await getGraphNodeCount(comfyPage)
|
||||
const afterUndoCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(afterUndoCount).toBe(initialCount - 1)
|
||||
|
||||
// Redo
|
||||
await comfyPage.keyboard.redo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterRedoCount = await getGraphNodeCount(comfyPage)
|
||||
const afterRedoCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(afterRedoCount).toBe(initialCount)
|
||||
})
|
||||
})
|
||||
@@ -643,17 +565,17 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
|
||||
@@ -750,9 +672,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
@@ -779,9 +699,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
// Navigate into subgraph (method now handles retries internally)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot('text')
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Wait for breadcrumb to be visible
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
@@ -881,19 +799,19 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Test that Escape no longer exits subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
if (!(await isInSubgraph(comfyPage))) {
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
// Test that Alt+Q now exits subgraph
|
||||
await comfyPage.page.keyboard.press('Alt+q')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
|
||||
@@ -907,7 +825,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
// Verify we're in a subgraph
|
||||
if (!(await isInSubgraph(comfyPage))) {
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
|
||||
@@ -927,12 +845,12 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should still be in subgraph
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Press Escape again - now should exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -40,13 +41,7 @@ test.describe(
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is in the node body, not the header
|
||||
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)
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNode, seedWidget)
|
||||
|
||||
// 3. Enter the subgraph and rename the seed slot.
|
||||
// The subgraph IO rename uses canvas.prompt() which requires the
|
||||
@@ -103,15 +98,7 @@ test.describe(
|
||||
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidgetAfter).toBeVisible()
|
||||
|
||||
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
|
||||
)
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
/**
|
||||
@@ -30,17 +31,7 @@ test.describe(
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(comfyPage.page)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -82,14 +81,7 @@ test.describe(
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
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()
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
@@ -98,14 +90,7 @@ test.describe(
|
||||
afterFirst
|
||||
)
|
||||
|
||||
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()
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
@@ -162,9 +147,7 @@ test.describe(
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
await clipNode.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
@@ -204,9 +187,7 @@ test.describe(
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
await clipNode.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
@@ -297,14 +278,9 @@ test.describe(
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
await subgraphNode.delete()
|
||||
|
||||
const nodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.canvas.graph!.getNodeById('5')
|
||||
})
|
||||
expect(nodeExists).toBe(false)
|
||||
expect(await subgraphNode.exists()).toBe(false)
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -8,17 +9,10 @@ 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: 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)
|
||||
}
|
||||
})
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(comfyPage.page, [
|
||||
'No link found',
|
||||
'Failed to resolve legacy -1'
|
||||
])
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
|
||||
@@ -60,29 +60,8 @@ test.describe(
|
||||
await expect(textWidget).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// 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()
|
||||
// 2. Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// 6. Re-enable Vue nodes and verify values are preserved
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
@@ -123,24 +102,8 @@ test.describe(
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// 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()
|
||||
// Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// Verify all proxyWidgets entries resolve
|
||||
await expect(async () => {
|
||||
|
||||
@@ -13,14 +13,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Find the subgraph node
|
||||
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()
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
// Simulate a stale progress value on the subgraph node.
|
||||
// This happens when:
|
||||
@@ -34,26 +27,21 @@ 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
|
||||
const inSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
expect(inSubgraph).toBe(true)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Navigate back to the root graph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
@@ -80,30 +68,18 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
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()
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
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()
|
||||
|
||||
const inSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
expect(inSubgraph).toBe(true)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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'
|
||||
@@ -72,14 +70,7 @@ test.describe(
|
||||
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
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.centerOnNode()
|
||||
|
||||
await saveNode.click('title')
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
@@ -360,6 +351,7 @@ 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)
|
||||
@@ -472,14 +464,7 @@ test.describe(
|
||||
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
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.centerOnNode()
|
||||
|
||||
await saveNode.click('title')
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
@@ -528,14 +513,7 @@ test.describe(
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
|
||||
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()
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
@@ -555,14 +533,7 @@ test.describe(
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
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()
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
@@ -714,15 +685,10 @@ test.describe(
|
||||
|
||||
// Delete the subgraph node
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
await subgraphNode.delete()
|
||||
|
||||
// Node no longer exists, so promoted widgets should be gone
|
||||
const nodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.canvas.graph!.getNodeById('11')
|
||||
})
|
||||
expect(nodeExists).toBe(false)
|
||||
expect(await subgraphNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
|
||||
@@ -748,9 +714,7 @@ test.describe(
|
||||
})
|
||||
expect(removedSlotName).not.toBeNull()
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.subgraph.removeSlot('input')
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
@@ -780,9 +744,7 @@ test.describe(
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Remove the text input slot
|
||||
await comfyPage.subgraph.rightClickInputSlot('text')
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
@@ -4,19 +4,8 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
async function createSubgraphAndNavigateInto(comfyPage: ComfyPage) {
|
||||
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]
|
||||
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Re-enable this test once issue resolved
|
||||
// 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).
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
|
||||
test.skip('should have all required thumbnail media for each template', async ({
|
||||
comfyPage
|
||||
@@ -72,9 +76,9 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
// Clear the workflow
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
}).toPass({ timeout: 250 })
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 250 })
|
||||
.toBe(0)
|
||||
|
||||
// Load a template
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
@@ -87,9 +91,9 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
|
||||
// Ensure we now have some nodes
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBeGreaterThan(0)
|
||||
}).toPass({ timeout: 250 })
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 250 })
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('dialog should be automatically shown to first-time users', async ({
|
||||
@@ -102,7 +106,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await comfyPage.setup({ clearStorage: true })
|
||||
|
||||
// Expect the templates dialog to be shown
|
||||
expect(await comfyPage.templates.content.isVisible()).toBe(true)
|
||||
await expect(comfyPage.templates.content).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Uses proper locale files for templates', async ({ comfyPage }) => {
|
||||
|
||||
@@ -25,37 +25,37 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
const nodeId = String(loadImageNode.id)
|
||||
const imagePreview = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.image-preview')
|
||||
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible({ timeout: 30_000 })
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
nodeId
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('opens mask editor from image preview button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('opens mask editor from image preview button', async ({ comfyPage }) => {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.locator('[role="img"]').focus()
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('shows image context menu options', async ({ comfyPage }) => {
|
||||
test('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')
|
||||
|
||||
@@ -76,10 +76,9 @@ test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
await comfyPage.page.keyboard.press('r')
|
||||
|
||||
// Wait for nodes' widgets to be updated
|
||||
await expect(async () => {
|
||||
const refreshedComboValues = await getComboValues()
|
||||
expect(refreshedComboValues).not.toEqual(initialComboValues)
|
||||
}).toPass({ timeout: 5000 })
|
||||
await expect
|
||||
.poll(() => getComboValues(), { timeout: 5000 })
|
||||
.not.toEqual(initialComboValues)
|
||||
})
|
||||
|
||||
test('Should refresh combo values of nodes with v2 combo input spec', async ({
|
||||
@@ -185,7 +184,9 @@ 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')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('load_image_widget.png', {
|
||||
maxDiffPixels: 50
|
||||
})
|
||||
})
|
||||
|
||||
test('Can drag and drop image', async ({ comfyPage }) => {
|
||||
@@ -227,14 +228,23 @@ 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()
|
||||
|
||||
// Stabilization for the image swap
|
||||
// Wait for the image to load from the server
|
||||
await imageLoaded
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'image_preview_changed_by_combo_value.png'
|
||||
'image_preview_changed_by_combo_value.png',
|
||||
{ maxDiffPixels: 50 }
|
||||
)
|
||||
|
||||
// Expect the filename combo value to be updated
|
||||
@@ -273,38 +283,6 @@ 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')
|
||||
|
||||
@@ -359,9 +337,11 @@ test.describe(
|
||||
},
|
||||
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(
|
||||
comfyPage.page.locator('.dom-widget').locator('img')
|
||||
).toHaveCount(2)
|
||||
).toHaveCount(2, { timeout: 10_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -90,10 +90,12 @@ test.describe('Workflow Tab Thumbnails', { tag: '@workflow' }, () => {
|
||||
const canvasArea = await comfyPage.canvas.boundingBox()
|
||||
|
||||
await comfyPage.page.mouse.move(
|
||||
canvasArea!.x + canvasArea!.width - 100,
|
||||
100
|
||||
canvasArea!.x + canvasArea!.width / 2,
|
||||
canvasArea!.y + canvasArea!.height / 2
|
||||
)
|
||||
await expect(comfyPage.page.locator('.workflow-popover-fade')).toHaveCount(
|
||||
0
|
||||
)
|
||||
await expect(comfyPage.page.locator('.workflow-popover-fade')).toBeHidden()
|
||||
|
||||
await comfyPage.canvasOps.rightClick(200, 200)
|
||||
await comfyPage.page.getByText('Add Node').click()
|
||||
|
||||
@@ -100,7 +100,7 @@ test.describe('Zoom Controls', { tag: '@canvas' }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 5000 })
|
||||
.toBeCloseTo(1.0, 1)
|
||||
|
||||
const zoomIn = comfyPage.page.getByTestId(TestIds.canvas.zoomInAction)
|
||||
|
||||
@@ -64,6 +64,13 @@ 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: [
|
||||
@@ -356,6 +363,14 @@ 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
|
||||
{
|
||||
@@ -370,7 +385,8 @@ export default defineConfig([
|
||||
importNames: ['t', 'd', 'te'],
|
||||
message:
|
||||
"In Vue components, use `const { t } = useI18n()` instead of importing from '@/i18n'."
|
||||
}
|
||||
},
|
||||
useVirtualListRestriction
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -390,10 +406,23 @@ 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]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
38
packages/design-system/src/css/_palette.css
Normal file
@@ -0,0 +1,38 @@
|
||||
@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);
|
||||
}
|
||||
@@ -5,42 +5,4 @@
|
||||
*/
|
||||
|
||||
@import './fonts.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);
|
||||
}
|
||||
@import './_palette.css';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@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';
|
||||
@@ -31,38 +32,10 @@
|
||||
--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;
|
||||
@@ -72,14 +45,6 @@
|
||||
--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;
|
||||
|
||||
@@ -357,6 +357,7 @@ export type {
|
||||
HubUsernameCheckResponse,
|
||||
HubWorkflowDetail,
|
||||
HubWorkflowListResponse,
|
||||
HubWorkflowStatus,
|
||||
HubWorkflowSummary,
|
||||
HubWorkflowTemplateEntry,
|
||||
ImportPublishedAssetsData,
|
||||
@@ -510,6 +511,13 @@ export type {
|
||||
SendUserInviteEmailResponse,
|
||||
SendUserInviteEmailResponse2,
|
||||
SendUserInviteEmailResponses,
|
||||
SetReviewStatusData,
|
||||
SetReviewStatusError,
|
||||
SetReviewStatusErrors,
|
||||
SetReviewStatusRequest,
|
||||
SetReviewStatusResponse,
|
||||
SetReviewStatusResponse2,
|
||||
SetReviewStatusResponses,
|
||||
SubmitFeedbackData,
|
||||
SubmitFeedbackError,
|
||||
SubmitFeedbackErrors,
|
||||
|
||||
84
packages/ingest-types/src/types.gen.ts
generated
@@ -151,6 +151,7 @@ export type HubWorkflowDetail = {
|
||||
share_id: string
|
||||
workflow_id: string
|
||||
name: string
|
||||
status: HubWorkflowStatus
|
||||
description?: string
|
||||
tags?: Array<LabelRef>
|
||||
thumbnail_type?: 'image' | 'video' | 'image_comparison'
|
||||
@@ -194,9 +195,19 @@ 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>
|
||||
@@ -245,6 +256,7 @@ export type HubWorkflowTemplateEntry = {
|
||||
*/
|
||||
name: string
|
||||
title: string
|
||||
status: HubWorkflowStatus
|
||||
description?: string
|
||||
tags?: Array<string>
|
||||
models?: Array<string>
|
||||
@@ -1629,6 +1641,28 @@ 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
|
||||
*/
|
||||
@@ -4457,6 +4491,45 @@ 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
|
||||
@@ -5740,6 +5813,10 @@ 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'
|
||||
}
|
||||
@@ -5814,7 +5891,12 @@ export type PublishHubWorkflowResponse =
|
||||
export type ListHubWorkflowIndexData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
query?: {
|
||||
/**
|
||||
* Filter by status (e.g. ?status=pending,approved). Defaults to approved if omitted.
|
||||
*/
|
||||
status?: Array<HubWorkflowStatus>
|
||||
}
|
||||
url: '/api/hub/workflows/index'
|
||||
}
|
||||
|
||||
|
||||
43
packages/ingest-types/src/zod.gen.ts
generated
@@ -58,10 +58,21 @@ 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(),
|
||||
@@ -81,6 +92,7 @@ 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(),
|
||||
@@ -114,6 +126,7 @@ 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(),
|
||||
@@ -870,6 +883,16 @@ 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
|
||||
*/
|
||||
@@ -1837,6 +1860,17 @@ 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(),
|
||||
@@ -2258,7 +2292,8 @@ export const zListHubWorkflowsData = z.object({
|
||||
search: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
detail: z.boolean().optional().default(false)
|
||||
detail: z.boolean().optional().default(false),
|
||||
status: z.array(zHubWorkflowStatus).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -2282,7 +2317,11 @@ export const zPublishHubWorkflowResponse = zHubWorkflowDetail
|
||||
export const zListHubWorkflowIndexData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
query: z
|
||||
.object({
|
||||
status: z.array(zHubWorkflowStatus).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<!-- Main toolbar -->
|
||||
<nav
|
||||
data-testid="builder-footer-nav"
|
||||
class="flex items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||
@@ -37,7 +38,11 @@
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<Button size="lg" :class="cn('w-24', disabledSaveClasses)">
|
||||
<Button
|
||||
size="lg"
|
||||
:class="cn('w-24', disabledSaveClasses)"
|
||||
data-testid="builder-save-as-button"
|
||||
>
|
||||
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</ConnectOutputPopover>
|
||||
@@ -50,6 +55,7 @@
|
||||
:disabled="!isModified"
|
||||
class="flex-1"
|
||||
:class="isModified ? activeSaveClasses : disabledSaveClasses"
|
||||
data-testid="builder-save-button"
|
||||
@click="save()"
|
||||
>
|
||||
{{ t('g.save') }}
|
||||
@@ -60,6 +66,7 @@
|
||||
size="lg"
|
||||
:aria-label="t('builderToolbar.saveAs')"
|
||||
data-save-chevron
|
||||
data-testid="builder-save-as-chevron"
|
||||
class="w-6 rounded-l-none border-l border-border-default px-0"
|
||||
>
|
||||
<i
|
||||
@@ -87,7 +94,13 @@
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</ButtonGroup>
|
||||
<Button v-else size="lg" :class="activeSaveClasses" @click="saveAs()">
|
||||
<Button
|
||||
v-else
|
||||
size="lg"
|
||||
:class="activeSaveClasses"
|
||||
data-testid="builder-save-as-button"
|
||||
@click="saveAs()"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useBuilderSave } from './useBuilderSave'
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockTrackEnterLinear = vi.hoisted(() => vi.fn())
|
||||
const mockTrackDefaultViewSet = vi.hoisted(() => vi.fn())
|
||||
const mockSaveWorkflow = vi.hoisted(() => vi.fn<() => Promise<void>>())
|
||||
const mockSaveWorkflowAs = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<boolean | null>>()
|
||||
@@ -13,7 +14,6 @@ const mockSaveWorkflowAs = vi.hoisted(() =>
|
||||
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
|
||||
const mockShowConfirmDialog = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockSetWorkflowDefaultView = vi.hoisted(() => vi.fn())
|
||||
const mockExitBuilder = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockActiveWorkflow = ref<{
|
||||
@@ -30,7 +30,10 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackEnterLinear: mockTrackEnterLinear })
|
||||
useTelemetry: () => ({
|
||||
trackEnterLinear: mockTrackEnterLinear,
|
||||
trackDefaultViewSet: mockTrackDefaultViewSet
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
@@ -60,10 +63,6 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ closeDialog: mockCloseDialog })
|
||||
}))
|
||||
|
||||
vi.mock('./builderViewOptions', () => ({
|
||||
setWorkflowDefaultView: mockSetWorkflowDefaultView
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
|
||||
showConfirmDialog: mockShowConfirmDialog
|
||||
}))
|
||||
@@ -190,7 +189,7 @@ describe('useBuilderSave', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('onSave calls saveWorkflowAs then setWorkflowDefaultView on success', async () => {
|
||||
it('onSave calls saveWorkflowAs with isApp and tracks telemetry', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
@@ -199,35 +198,40 @@ describe('useBuilderSave', () => {
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
{
|
||||
filename: 'new-name'
|
||||
filename: 'new-name',
|
||||
isApp: true
|
||||
}
|
||||
)
|
||||
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
true
|
||||
)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'app'
|
||||
})
|
||||
})
|
||||
|
||||
it('onSave uses fresh activeWorkflow reference for setWorkflowDefaultView', async () => {
|
||||
const newWorkflow = { filename: 'new-name', initialMode: 'app' }
|
||||
mockSaveWorkflowAs.mockImplementationOnce(async () => {
|
||||
mockActiveWorkflow.value = newWorkflow
|
||||
return true
|
||||
})
|
||||
it('onSave passes isApp: false when saving as graph', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(newWorkflow, true)
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
{
|
||||
filename: 'new-name',
|
||||
isApp: false
|
||||
}
|
||||
)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'graph'
|
||||
})
|
||||
})
|
||||
|
||||
it('onSave does not mutate or close when saveWorkflowAs returns falsy', async () => {
|
||||
it('onSave does not track or close when saveWorkflowAs returns falsy', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(null)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockSetWorkflowDefaultView).not.toHaveBeenCalled()
|
||||
expect(mockTrackDefaultViewSet).not.toHaveBeenCalled()
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { setWorkflowDefaultView } from './builderViewOptions'
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
@@ -71,13 +70,14 @@ export function useBuilderSave() {
|
||||
if (!workflow) return
|
||||
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename
|
||||
filename,
|
||||
isApp: openAsApp
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (!activeWorkflow) return
|
||||
setWorkflowDefaultView(activeWorkflow, openAsApp)
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog(SAVE_DIALOG_KEY)
|
||||
showSuccessDialog(openAsApp ? 'app' : 'graph')
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="tooltipText"
|
||||
v-show="tooltipText"
|
||||
ref="tooltipRef"
|
||||
class="node-tooltip"
|
||||
:style="{ left, top }"
|
||||
@@ -34,7 +34,8 @@ const left = ref<string>()
|
||||
const top = ref<string>()
|
||||
|
||||
function hideTooltip() {
|
||||
return (tooltipText.value = '')
|
||||
if (!tooltipText.value) return
|
||||
tooltipText.value = ''
|
||||
}
|
||||
|
||||
async function showTooltip(tooltip: string | null | undefined) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
v-show="isVisible"
|
||||
class="pointer-events-none absolute z-9999 border border-blue-400 bg-blue-500/20"
|
||||
:style="rectangleStyle"
|
||||
/>
|
||||
|
||||
@@ -37,8 +37,6 @@ export function useAppMode() {
|
||||
)
|
||||
|
||||
function setMode(newMode: AppMode) {
|
||||
if (newMode === mode.value) return
|
||||
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow) workflow.activeMode = newMode
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick, reactive } from 'vue'
|
||||
import type { EffectScope } from 'vue'
|
||||
|
||||
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
|
||||
@@ -88,7 +87,7 @@ describe('useBrowserTabTitle', () => {
|
||||
})
|
||||
|
||||
it('sets default title when idle and no workflow', () => {
|
||||
const scope: EffectScope = effectScope()
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
expect(document.title).toBe('ComfyUI')
|
||||
scope.stop()
|
||||
@@ -101,7 +100,7 @@ describe('useBrowserTabTitle', () => {
|
||||
isModified: false,
|
||||
isPersisted: true
|
||||
}
|
||||
const scope: EffectScope = effectScope()
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('myFlow - ComfyUI')
|
||||
@@ -115,7 +114,7 @@ describe('useBrowserTabTitle', () => {
|
||||
isModified: true,
|
||||
isPersisted: true
|
||||
}
|
||||
const scope: EffectScope = effectScope()
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('*myFlow - ComfyUI')
|
||||
@@ -133,9 +132,11 @@ describe('useBrowserTabTitle', () => {
|
||||
isModified: true,
|
||||
isPersisted: true
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('myFlow - ComfyUI')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('hides asterisk while Shift key is held', async () => {
|
||||
@@ -150,21 +151,21 @@ describe('useBrowserTabTitle', () => {
|
||||
isModified: true,
|
||||
isPersisted: true
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('myFlow - ComfyUI')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
// 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 () => {
|
||||
it('disables workflow title when menu disabled', async () => {
|
||||
vi.mocked(settingStore.get).mockReturnValue('Disabled')
|
||||
workflowStore.activeWorkflow = {
|
||||
filename: 'myFlow',
|
||||
isModified: false,
|
||||
isPersisted: true
|
||||
}
|
||||
const scope: EffectScope = effectScope()
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('ComfyUI')
|
||||
@@ -174,7 +175,7 @@ describe('useBrowserTabTitle', () => {
|
||||
it('shows execution progress when not idle without workflow', async () => {
|
||||
executionStore.isIdle = false
|
||||
executionStore.executionProgress = 0.3
|
||||
const scope: EffectScope = effectScope()
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[30%]ComfyUI')
|
||||
@@ -196,7 +197,7 @@ describe('useBrowserTabTitle', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
const scope: EffectScope = effectScope()
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[40%][50%] Foo')
|
||||
@@ -216,7 +217,7 @@ describe('useBrowserTabTitle', () => {
|
||||
},
|
||||
'2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' }
|
||||
}
|
||||
const scope: EffectScope = effectScope()
|
||||
const scope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[40%][2 nodes running]')
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// 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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
// 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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -39,29 +39,3 @@ 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: []
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { test as baseTest } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ISerialisedGraph,
|
||||
SerialisableGraph
|
||||
@@ -12,11 +11,7 @@ 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 {
|
||||
basicSerialisableGraph,
|
||||
minimalSerialisableGraph,
|
||||
oldSchemaGraph
|
||||
} from './assets/testGraphs'
|
||||
import { minimalSerialisableGraph, oldSchemaGraph } from './assets/testGraphs'
|
||||
|
||||
interface LitegraphFixtures {
|
||||
minimalGraph: LGraph
|
||||
@@ -28,11 +23,7 @@ interface LitegraphFixtures {
|
||||
reroutesComplexGraph: LGraph
|
||||
}
|
||||
|
||||
/** These fixtures alter global state, and are difficult to reset. Relies on a single test per-file to reset state. */
|
||||
interface DirtyFixtures {
|
||||
basicSerialisableGraph: SerialisableGraph
|
||||
}
|
||||
|
||||
/** LiteGraph test fixtures. Each creates an LGraph from cloned data; LGraph singletons may still share some global state. */
|
||||
export const test = baseTest.extend<LitegraphFixtures>({
|
||||
minimalGraph: async ({}, use) => {
|
||||
// Before each test function
|
||||
@@ -65,17 +56,3 @@ 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))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// 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,
|
||||
@@ -42,7 +40,7 @@ function mockRenderLinkImpl(canConnect: boolean): RenderLinkItem {
|
||||
const mockNode = createMockLGraphNode()
|
||||
const mockInput = createMockNodeInputSlot()
|
||||
|
||||
describe.skip('LinkConnector', () => {
|
||||
describe('LinkConnector', () => {
|
||||
let connector: LinkConnector
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -52,7 +50,7 @@ describe.skip('LinkConnector', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe.skip('isInputValidDrop', () => {
|
||||
describe('isInputValidDrop', () => {
|
||||
test('should return false if there are no render links', () => {
|
||||
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(false)
|
||||
})
|
||||
@@ -74,110 +72,5 @@ describe.skip('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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,3 +25,34 @@ 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2358,6 +2358,7 @@
|
||||
"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",
|
||||
|
||||
@@ -126,6 +126,7 @@ 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}',
|
||||
@@ -200,8 +201,8 @@ function createWrapper(overrides = {}) {
|
||||
SubscriptionBenefits: true,
|
||||
Button: {
|
||||
template:
|
||||
'<button @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon"><slot/></button>',
|
||||
props: ['variant', 'size'],
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon"><slot/></button>',
|
||||
props: ['variant', 'size', 'loading', 'label', 'icon'],
|
||||
emits: ['click']
|
||||
},
|
||||
Skeleton: {
|
||||
@@ -213,6 +214,17 @@ 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()
|
||||
@@ -221,6 +233,8 @@ describe('SubscriptionPanel', () => {
|
||||
mockIsCancelled.value = false
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
mockIsYearlySubscription.value = false
|
||||
mockCreditsData.isLoadingBalance = false
|
||||
mockActionsData.isLoadingSupport = false
|
||||
})
|
||||
|
||||
describe('subscription state functionality', () => {
|
||||
@@ -295,7 +309,7 @@ describe('SubscriptionPanel', () => {
|
||||
|
||||
mockIsActiveSubscription.value = true
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Included (Refills 12/31/24)')
|
||||
expect(wrapper.text()).toMatch(/Included \(Refills \d{2}\/\d{2}\/\d{2}\)/)
|
||||
expect(wrapper.text()).not.toContain('/')
|
||||
|
||||
vi.useRealTimers()
|
||||
@@ -303,43 +317,41 @@ describe('SubscriptionPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Re-enable when migrating to VTL so we can find by user visible content.
|
||||
describe.skip('action buttons', () => {
|
||||
describe('action buttons', () => {
|
||||
it('should call handleLearnMoreClick when learn more is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const learnMoreButton = wrapper.find('[data-testid="Learn More"]')
|
||||
const learnMoreButton = findButtonByText(wrapper, '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 = wrapper.find('[data-testid="Message Support"]')
|
||||
const supportButton = findButtonByText(wrapper, 'Message Support')
|
||||
await supportButton.trigger('click')
|
||||
expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should call handleRefresh when refresh button is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
// Find the refresh button by icon
|
||||
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
|
||||
const refreshButton = wrapper.find('button[aria-label="Refresh credits"]')
|
||||
await refreshButton.trigger('click')
|
||||
expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('loading states', () => {
|
||||
describe('loading states', () => {
|
||||
it('should show loading state on support button when loading', () => {
|
||||
mockActionsData.isLoadingSupport = true
|
||||
const wrapper = createWrapper()
|
||||
const supportButton = wrapper.find('[data-testid="Message Support"]')
|
||||
const supportButton = findButtonByText(wrapper, '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('[data-icon="pi pi-sync"]')
|
||||
const refreshButton = wrapper.find('button[aria-label="Refresh credits"]')
|
||||
expect(refreshButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
:aria-label="$t('subscription.refreshCredits')"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-sm text-text-secondary" />
|
||||
|
||||
@@ -71,7 +71,7 @@ vi.mock('@/services/dialogService', () => ({
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: { ds: { offset: [0, 0], scale: 1 } },
|
||||
rootGraph: { serialize: vi.fn(() => ({})) },
|
||||
rootGraph: { serialize: vi.fn(() => ({})), extra: {} },
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
@@ -93,7 +93,11 @@ vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
useTelemetry: () => ({
|
||||
trackDefaultViewSet: vi.fn(),
|
||||
trackWorkflowSaved: vi.fn(),
|
||||
trackEnterLinear: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStore', () => ({
|
||||
@@ -328,48 +332,6 @@ describe('useWorkflowService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWorkflowAs', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
workflowStore = useWorkflowStore()
|
||||
})
|
||||
|
||||
it('should rename then save when workflow is temporary', async () => {
|
||||
const workflow = createModeTestWorkflow({
|
||||
path: 'workflows/Unsaved Workflow.json'
|
||||
})
|
||||
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
|
||||
vi.mocked(workflowStore.getWorkflowByPath).mockReturnValue(null)
|
||||
vi.mocked(workflowStore.renameWorkflow).mockResolvedValue()
|
||||
vi.mocked(workflowStore.saveWorkflow).mockResolvedValue()
|
||||
|
||||
const result = await useWorkflowService().saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow'
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
|
||||
it('should return false when no filename is provided', async () => {
|
||||
const workflow = createModeTestWorkflow({
|
||||
path: 'workflows/test.json'
|
||||
})
|
||||
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
|
||||
|
||||
const result = await useWorkflowService().saveWorkflowAs(workflow)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('afterLoadNewGraph', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
let existingWorkflow: LoadedComfyWorkflow
|
||||
@@ -538,6 +500,20 @@ describe('useWorkflowService', () => {
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
expect(appMode.mode.value).toBe('builder:arrange')
|
||||
})
|
||||
|
||||
it('sets activeMode even when initialMode already matches', () => {
|
||||
const workflow = createModeTestWorkflow({
|
||||
initialMode: 'app',
|
||||
activeMode: null
|
||||
})
|
||||
workflowStore.activeWorkflow = workflow
|
||||
|
||||
// mode.value is 'app' via initialMode fallback, but activeMode
|
||||
// must still be set so the UI transitions to app view
|
||||
appMode.setMode('app')
|
||||
|
||||
expect(workflow.activeMode).toBe('app')
|
||||
})
|
||||
})
|
||||
|
||||
describe('afterLoadNewGraph initializes initialMode', () => {
|
||||
@@ -686,6 +662,7 @@ describe('useWorkflowService', () => {
|
||||
service = useWorkflowService()
|
||||
vi.spyOn(workflowStore, 'saveWorkflow').mockResolvedValue()
|
||||
vi.spyOn(workflowStore, 'renameWorkflow').mockResolvedValue()
|
||||
app.rootGraph.extra = {}
|
||||
})
|
||||
|
||||
function createTemporaryWorkflow(
|
||||
@@ -703,6 +680,34 @@ describe('useWorkflowService', () => {
|
||||
return workflow as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
it('should rename then save when workflow is temporary', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
vi.mocked(workflowStore.getWorkflowByPath).mockReturnValue(null)
|
||||
|
||||
const result = await service.saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow'
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
|
||||
it('should return false when no filename is provided', async () => {
|
||||
const workflow = createModeTestWorkflow({
|
||||
path: 'workflows/test.json'
|
||||
})
|
||||
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
|
||||
|
||||
const result = await service.saveWorkflowAs(workflow)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('appends .app.json extension when initialMode is app', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'app'
|
||||
@@ -737,6 +742,211 @@ describe('useWorkflowService', () => {
|
||||
'workflows/my-workflow.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses isApp option over initialMode when provided (graph -> app)', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'graph'
|
||||
|
||||
await service.saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.app.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses isApp option over initialMode when provided (app -> graph)', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'app'
|
||||
|
||||
await service.saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow',
|
||||
isApp: false
|
||||
})
|
||||
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a copy when saving same name with different mode (not self-overwrite)', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/test.json',
|
||||
initialMode: 'graph'
|
||||
})
|
||||
|
||||
const copy = createModeTestWorkflow({
|
||||
path: 'workflows/test.app.json'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'test',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
// Different extension means different path, so it's not a self-overwrite
|
||||
// — a new copy is created instead of modifying the source in place
|
||||
expect(source.initialMode).toBe('graph')
|
||||
expect(workflowStore.saveAs).toHaveBeenCalledWith(
|
||||
source,
|
||||
'workflows/test.app.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(copy)
|
||||
})
|
||||
|
||||
it('self-overwrites when saving same name with same mode', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/test.app.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(source)
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'test',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
// Same path → self-overwrite: saves in place via saveWorkflow, no copy
|
||||
expect(workflowStore.saveAs).not.toHaveBeenCalled()
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(source)
|
||||
})
|
||||
|
||||
it('does not modify source workflow mode when saving persisted workflow as different mode', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/original.json',
|
||||
initialMode: 'graph'
|
||||
})
|
||||
|
||||
const copy = createModeTestWorkflow({
|
||||
path: 'workflows/copy.app.json'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'copy',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
expect(source.initialMode).toBe('graph')
|
||||
expect(copy.initialMode).toBe('app')
|
||||
expect(workflowStore.saveAs).toHaveBeenCalledWith(
|
||||
source,
|
||||
'workflows/copy.app.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(copy)
|
||||
})
|
||||
|
||||
it('does not modify source workflow mode when saving app as graph', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/original.app.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
|
||||
const copy = createModeTestWorkflow({
|
||||
path: 'workflows/copy.json'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'copy',
|
||||
isApp: false
|
||||
})
|
||||
|
||||
expect(source.initialMode).toBe('app')
|
||||
expect(copy.initialMode).toBe('graph')
|
||||
expect(workflowStore.saveAs).toHaveBeenCalledWith(
|
||||
source,
|
||||
'workflows/copy.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(copy)
|
||||
})
|
||||
|
||||
function captureLinearModeAtSaveTime() {
|
||||
let value: boolean | undefined
|
||||
vi.mocked(workflowStore.saveWorkflow).mockImplementation(async () => {
|
||||
value = app.rootGraph.extra?.linearMode as boolean | undefined
|
||||
})
|
||||
return () => value
|
||||
}
|
||||
|
||||
it('sets linearMode in graph data before saving (graph -> app)', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'graph'
|
||||
app.rootGraph.extra = { linearMode: false }
|
||||
const getLinearMode = captureLinearModeAtSaveTime()
|
||||
|
||||
await service.saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
expect(getLinearMode()).toBe(true)
|
||||
})
|
||||
|
||||
it('sets linearMode in graph data before saving (app -> graph)', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'app'
|
||||
app.rootGraph.extra = { linearMode: true }
|
||||
const getLinearMode = captureLinearModeAtSaveTime()
|
||||
|
||||
await service.saveWorkflowAs(workflow, {
|
||||
filename: 'my-workflow',
|
||||
isApp: false
|
||||
})
|
||||
|
||||
expect(getLinearMode()).toBe(false)
|
||||
})
|
||||
|
||||
it('sets linearMode before saving persisted workflow copy', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/original.json',
|
||||
initialMode: 'graph'
|
||||
})
|
||||
app.rootGraph.extra = { linearMode: false }
|
||||
|
||||
const copy = createModeTestWorkflow({
|
||||
path: 'workflows/original.app.json'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
|
||||
const getLinearMode = captureLinearModeAtSaveTime()
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'original',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
expect(getLinearMode()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not change initialMode when isApp is omitted (persisted copy)', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/original.app.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
|
||||
// Real saveAs copies initialMode from source; replicate that here
|
||||
const copy = createModeTestWorkflow({
|
||||
path: 'workflows/copy.app.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
|
||||
|
||||
await service.saveWorkflowAs(source, { filename: 'copy' })
|
||||
|
||||
// saveWorkflowAs should not change initialMode when isApp is omitted
|
||||
expect(copy.initialMode).toBe('app')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWorkflow', () => {
|
||||
|
||||
@@ -116,12 +116,12 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const saveWorkflowAs = async (
|
||||
workflow: ComfyWorkflow,
|
||||
options: { filename?: string } = {}
|
||||
options: { filename?: string; isApp?: boolean } = {}
|
||||
): Promise<boolean> => {
|
||||
const newFilename = options.filename ?? (await workflow.promptSave())
|
||||
if (!newFilename) return false
|
||||
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const isApp = options.isApp ?? workflow.initialMode === 'app'
|
||||
const newPath =
|
||||
workflow.directory + '/' + appendWorkflowJsonExt(newFilename, isApp)
|
||||
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
|
||||
@@ -138,17 +138,27 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
workflow.changeTracker?.checkState()
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
workflow.changeTracker?.checkState()
|
||||
await saveWorkflow(workflow)
|
||||
} else if (workflow.isTemporary) {
|
||||
await renameWorkflow(workflow, newPath)
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
} else {
|
||||
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
|
||||
await openWorkflow(tempWorkflow)
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
let target: ComfyWorkflow
|
||||
if (workflow.isTemporary) {
|
||||
await renameWorkflow(workflow, newPath)
|
||||
target = workflow
|
||||
} else {
|
||||
target = workflowStore.saveAs(workflow, newPath)
|
||||
await openWorkflow(target)
|
||||
}
|
||||
|
||||
if (options.isApp !== undefined) {
|
||||
app.rootGraph.extra ??= {}
|
||||
app.rootGraph.extra.linearMode = isApp
|
||||
target.initialMode = isApp ? 'app' : 'graph'
|
||||
}
|
||||
target.changeTracker?.checkState()
|
||||
|
||||
await workflowStore.saveWorkflow(target)
|
||||
}
|
||||
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
|
||||
|
||||
@@ -282,6 +282,7 @@ const zExtra = z
|
||||
workflowRendererVersion: zRendererType.optional(),
|
||||
BlueprintDescription: z.string().optional(),
|
||||
BlueprintSearchAliases: z.array(z.string()).optional(),
|
||||
linearMode: z.boolean().optional(),
|
||||
linearData: z
|
||||
.object({
|
||||
inputs: z.array(z.tuple([zNodeId, z.string()])).optional(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
@@ -288,27 +287,7 @@ const workspaceApiClient = axios.create({
|
||||
})
|
||||
|
||||
async function getAuthHeaderOrThrow() {
|
||||
const authHeader = await useAuthStore().getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new WorkspaceApiError(
|
||||
t('toastMessages.userNotAuthenticated'),
|
||||
401,
|
||||
'NOT_AUTHENTICATED'
|
||||
)
|
||||
}
|
||||
return authHeader
|
||||
}
|
||||
|
||||
async function getFirebaseHeaderOrThrow() {
|
||||
const authHeader = await useAuthStore().getFirebaseAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new WorkspaceApiError(
|
||||
t('toastMessages.userNotAuthenticated'),
|
||||
401,
|
||||
'NOT_AUTHENTICATED'
|
||||
)
|
||||
}
|
||||
return authHeader
|
||||
return useAuthStore().getAuthHeaderOrThrow()
|
||||
}
|
||||
|
||||
function handleAxiosError(err: unknown): never {
|
||||
@@ -500,7 +479,7 @@ export const workspaceApi = {
|
||||
* Uses Firebase auth (user identity) since the user isn't yet a workspace member.
|
||||
*/
|
||||
async acceptInvite(token: string): Promise<AcceptInviteResponse> {
|
||||
const headers = await getFirebaseHeaderOrThrow()
|
||||
const headers = await useAuthStore().getFirebaseAuthHeaderOrThrow()
|
||||
try {
|
||||
const response = await workspaceApiClient.post<AcceptInviteResponse>(
|
||||
api.apiURL(`/invites/${token}/accept`),
|
||||
|
||||
@@ -343,6 +343,10 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkspaceToken(): string | undefined {
|
||||
return workspaceToken.value ?? undefined
|
||||
}
|
||||
|
||||
function clearWorkspaceContext(): void {
|
||||
// Increment request ID to invalidate any in-flight stale refresh operations
|
||||
refreshRequestId++
|
||||
@@ -370,6 +374,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
switchWorkspace,
|
||||
refreshToken,
|
||||
getWorkspaceAuthHeader,
|
||||
getWorkspaceToken,
|
||||
clearWorkspaceContext
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
@contextmenu="handleContextMenu"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<!-- Selection/Execution Outline Overlay -->
|
||||
<AppOutput
|
||||
@@ -834,6 +834,9 @@ function handleDrop(event: DragEvent) {
|
||||
if (!node?.onDragDrop) return
|
||||
|
||||
const handled = node.onDragDrop(event)
|
||||
if (handled === true) event.stopPropagation()
|
||||
if (handled === true) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -157,31 +157,6 @@ describe('useNodePointerInteractions', () => {
|
||||
expect(startDrag).toHaveBeenCalledWith(leftClickEvent, 'test-node-123')
|
||||
})
|
||||
|
||||
it.skip('should call onNodeSelect on pointer down', async () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
// Selection should happen on pointer down
|
||||
const downEvent = createPointerEvent('pointerdown', {
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
})
|
||||
pointerHandlers.onPointerdown(downEvent)
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith(downEvent, 'test-node-123')
|
||||
|
||||
vi.mocked(handleNodeSelect).mockClear()
|
||||
|
||||
// Even if we drag, selection already happened on pointer down
|
||||
pointerHandlers.onPointerup(
|
||||
createPointerEvent('pointerup', { clientX: 200, clientY: 200 })
|
||||
)
|
||||
|
||||
// onNodeSelect should not be called again on pointer up
|
||||
expect(handleNodeSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle drag termination via cancel and context menu', async () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -40,7 +41,14 @@ function addMultilineWidget(
|
||||
})
|
||||
|
||||
widget.element = inputEl
|
||||
widget.inputEl = inputEl
|
||||
|
||||
/** @deprecated Use {@link widget.element} instead (renamed in PR #8594). */
|
||||
defineDeprecatedProperty(
|
||||
widget,
|
||||
'inputEl',
|
||||
'element',
|
||||
'widget.inputEl is deprecated. Use widget.element instead.'
|
||||
)
|
||||
widget.options.minNodeSize = [400, 200]
|
||||
|
||||
inputEl.addEventListener('input', (event) => {
|
||||
|
||||
111
src/stores/__tests__/authStoreMock.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { vi } from 'vitest'
|
||||
import type { Mock } from 'vitest'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Shared mock factory for useAuthStore.
|
||||
*
|
||||
* Usage in test files:
|
||||
* import { createAuthStoreMock, mockAuthStoreModule } from '@/stores/__tests__/authStoreMock'
|
||||
*
|
||||
* const { mock, controls } = createAuthStoreMock()
|
||||
* vi.mock('@/stores/authStore', () => mockAuthStoreModule(mock))
|
||||
*
|
||||
* // Per-test customization:
|
||||
* controls.currentUser.value = { uid: 'test-123', email: 'a@b.com' }
|
||||
* controls.getAuthHeader.mockResolvedValue({ Authorization: 'Bearer tok' })
|
||||
*/
|
||||
|
||||
export interface AuthStoreMockControls {
|
||||
currentUser: ReturnType<typeof ref<Record<string, unknown> | null>>
|
||||
isInitialized: ReturnType<typeof ref<boolean>>
|
||||
loading: ReturnType<typeof ref<boolean>>
|
||||
balance: ReturnType<typeof ref<Record<string, unknown> | null>>
|
||||
isFetchingBalance: ReturnType<typeof ref<boolean>>
|
||||
tokenRefreshTrigger: ReturnType<typeof ref<number>>
|
||||
|
||||
login: Mock
|
||||
register: Mock
|
||||
logout: Mock
|
||||
getIdToken: Mock
|
||||
getAuthHeader: Mock
|
||||
getAuthHeaderOrThrow: Mock
|
||||
getFirebaseAuthHeader: Mock
|
||||
getFirebaseAuthHeaderOrThrow: Mock
|
||||
getAuthToken: Mock
|
||||
createCustomer: Mock
|
||||
fetchBalance: Mock
|
||||
accessBillingPortal: Mock
|
||||
loginWithGoogle: Mock
|
||||
loginWithGithub: Mock
|
||||
sendPasswordReset: Mock
|
||||
updatePassword: Mock
|
||||
initiateCreditPurchase: Mock
|
||||
}
|
||||
|
||||
export function createAuthStoreMock(): {
|
||||
mock: Record<string, unknown>
|
||||
controls: AuthStoreMockControls
|
||||
} {
|
||||
const currentUser = ref<Record<string, unknown> | null>(null)
|
||||
const isInitialized = ref(false)
|
||||
const loading = ref(false)
|
||||
const balance = ref<Record<string, unknown> | null>(null)
|
||||
const isFetchingBalance = ref(false)
|
||||
const tokenRefreshTrigger = ref(0)
|
||||
|
||||
const controls: AuthStoreMockControls = {
|
||||
currentUser,
|
||||
isInitialized,
|
||||
loading,
|
||||
balance,
|
||||
isFetchingBalance,
|
||||
tokenRefreshTrigger,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
getIdToken: vi.fn().mockResolvedValue('mock-id-token'),
|
||||
getAuthHeader: vi.fn().mockResolvedValue(null),
|
||||
getAuthHeaderOrThrow: vi.fn().mockResolvedValue({
|
||||
Authorization: 'Bearer mock-id-token'
|
||||
}),
|
||||
getFirebaseAuthHeader: vi.fn().mockResolvedValue(null),
|
||||
getFirebaseAuthHeaderOrThrow: vi.fn().mockResolvedValue({
|
||||
Authorization: 'Bearer mock-id-token'
|
||||
}),
|
||||
getAuthToken: vi.fn().mockResolvedValue(undefined),
|
||||
createCustomer: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
accessBillingPortal: vi.fn(),
|
||||
loginWithGoogle: vi.fn(),
|
||||
loginWithGithub: vi.fn(),
|
||||
sendPasswordReset: vi.fn(),
|
||||
updatePassword: vi.fn(),
|
||||
initiateCreditPurchase: vi.fn()
|
||||
}
|
||||
|
||||
const mock = reactive({
|
||||
...controls,
|
||||
isAuthenticated: computed(() => !!currentUser.value),
|
||||
userEmail: computed(
|
||||
() => (currentUser.value as Record<string, unknown> | null)?.email ?? null
|
||||
),
|
||||
userId: computed(
|
||||
() => (currentUser.value as Record<string, unknown> | null)?.uid ?? null
|
||||
)
|
||||
})
|
||||
|
||||
return { mock, controls }
|
||||
}
|
||||
|
||||
export function mockAuthStoreModule(mock: Record<string, unknown>) {
|
||||
return {
|
||||
useAuthStore: () => mock,
|
||||
AuthStoreError: class extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'AuthStoreError'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
211
src/stores/__tests__/authTokenPriority.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { User } from 'firebase/auth'
|
||||
import * as firebaseAuth from 'firebase/auth'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import type { Mock } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as vuefire from 'vuefire'
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
const { mockFeatureFlags } = vi.hoisted(() => ({
|
||||
mockFeatureFlags: {
|
||||
teamWorkspacesEnabled: false
|
||||
}
|
||||
}))
|
||||
|
||||
const { mockDistributionTypes } = vi.hoisted(() => ({
|
||||
mockDistributionTypes: {
|
||||
isCloud: true,
|
||||
isDesktop: true
|
||||
}
|
||||
}))
|
||||
|
||||
const mockWorkspaceAuthHeader = vi.fn().mockReturnValue(null)
|
||||
const mockGetWorkspaceToken = vi.fn().mockReturnValue(undefined)
|
||||
const mockClearWorkspaceContext = vi.fn()
|
||||
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => ({
|
||||
getWorkspaceAuthHeader: mockWorkspaceAuthHeader,
|
||||
getWorkspaceToken: mockGetWorkspaceToken,
|
||||
clearWorkspaceContext: mockClearWorkspaceContext
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: mockFeatureFlags
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vuefire', () => ({
|
||||
useFirebaseAuth: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
createI18n: () => ({ global: { t: (key: string) => key } })
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof firebaseAuth>()
|
||||
return {
|
||||
...actual,
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
createUserWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
onAuthStateChanged: vi.fn(),
|
||||
onIdTokenChanged: vi.fn(),
|
||||
signInWithPopup: vi.fn(),
|
||||
GoogleAuthProvider: class {
|
||||
addScope = vi.fn()
|
||||
setCustomParameters = vi.fn()
|
||||
},
|
||||
GithubAuthProvider: class {
|
||||
addScope = vi.fn()
|
||||
setCustomParameters = vi.fn()
|
||||
},
|
||||
getAdditionalUserInfo: vi.fn(),
|
||||
setPersistence: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackAuth: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/toastStore', () => ({
|
||||
useToastStore: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService')
|
||||
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
|
||||
|
||||
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: () => ({
|
||||
getAuthHeader: mockApiKeyGetAuthHeader,
|
||||
getApiKey: vi.fn(),
|
||||
currentUser: null,
|
||||
isAuthenticated: false,
|
||||
storeApiKey: vi.fn(),
|
||||
clearStoredApiKey: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
type MockUser = Omit<User, 'getIdToken'> & { getIdToken: Mock }
|
||||
|
||||
describe('auth token priority chain', () => {
|
||||
let store: ReturnType<typeof useAuthStore>
|
||||
let authStateCallback: (user: User | null) => void
|
||||
|
||||
const mockAuth: Record<string, unknown> = {}
|
||||
|
||||
const mockUser: MockUser = {
|
||||
uid: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
getIdToken: vi.fn().mockResolvedValue('firebase-token')
|
||||
} as Partial<User> as MockUser
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
|
||||
mockFeatureFlags.teamWorkspacesEnabled = false
|
||||
mockWorkspaceAuthHeader.mockReturnValue(null)
|
||||
mockGetWorkspaceToken.mockReturnValue(undefined)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
||||
mockUser.getIdToken.mockResolvedValue('firebase-token')
|
||||
|
||||
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(
|
||||
mockAuth as unknown as ReturnType<typeof vuefire.useFirebaseAuth>
|
||||
)
|
||||
|
||||
vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation(
|
||||
(_, callback) => {
|
||||
authStateCallback = callback as (user: User | null) => void
|
||||
;(callback as (user: User | null) => void)(mockUser)
|
||||
return vi.fn()
|
||||
}
|
||||
)
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useAuthStore()
|
||||
})
|
||||
|
||||
describe('getAuthHeader priority', () => {
|
||||
it('returns workspace auth header when workspace is active and feature enabled', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockWorkspaceAuthHeader.mockReturnValue({
|
||||
Authorization: 'Bearer workspace-token'
|
||||
})
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toEqual({
|
||||
Authorization: 'Bearer workspace-token'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns Firebase token when workspace is not active but user is authenticated', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockWorkspaceAuthHeader.mockReturnValue(null)
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toEqual({
|
||||
Authorization: 'Bearer firebase-token'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns API key when neither workspace nor Firebase are available', async () => {
|
||||
authStateCallback(null)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue({ 'X-API-KEY': 'test-key' })
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toEqual({ 'X-API-KEY': 'test-key' })
|
||||
})
|
||||
|
||||
it('returns null when no auth method is available', async () => {
|
||||
authStateCallback(null)
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toBeNull()
|
||||
})
|
||||
|
||||
it('skips workspace header when team_workspaces feature is disabled', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = false
|
||||
mockWorkspaceAuthHeader.mockReturnValue({
|
||||
Authorization: 'Bearer workspace-token'
|
||||
})
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toEqual({
|
||||
Authorization: 'Bearer firebase-token'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAuthToken priority', () => {
|
||||
it('returns workspace token when workspace is active and feature enabled', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockGetWorkspaceToken.mockReturnValue('workspace-raw-token')
|
||||
|
||||
const token = await store.getAuthToken()
|
||||
|
||||
expect(token).toBe('workspace-raw-token')
|
||||
})
|
||||
|
||||
it('returns Firebase token when workspace token is not available', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockGetWorkspaceToken.mockReturnValue(undefined)
|
||||
|
||||
const token = await store.getAuthToken()
|
||||
|
||||
expect(token).toBe('firebase-token')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -730,6 +730,37 @@ describe('useAuthStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAuthHeaderOrThrow', () => {
|
||||
it('returns auth header when authenticated', async () => {
|
||||
const header = await store.getAuthHeaderOrThrow()
|
||||
expect(header).toEqual({ Authorization: 'Bearer mock-id-token' })
|
||||
})
|
||||
|
||||
it('throws AuthStoreError when not authenticated', async () => {
|
||||
authStateCallback(null)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
||||
|
||||
await expect(store.getAuthHeaderOrThrow()).rejects.toThrow(
|
||||
'toastMessages.userNotAuthenticated'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFirebaseAuthHeaderOrThrow', () => {
|
||||
it('returns Firebase auth header when authenticated', async () => {
|
||||
const header = await store.getFirebaseAuthHeaderOrThrow()
|
||||
expect(header).toEqual({ Authorization: 'Bearer mock-id-token' })
|
||||
})
|
||||
|
||||
it('throws AuthStoreError when not authenticated', async () => {
|
||||
authStateCallback(null)
|
||||
|
||||
await expect(store.getFirebaseAuthHeaderOrThrow()).rejects.toThrow(
|
||||
'toastMessages.userNotAuthenticated'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCustomer', () => {
|
||||
it('should succeed with API key auth when no Firebase user is present', async () => {
|
||||
authStateCallback(null)
|
||||
|
||||
@@ -22,10 +22,10 @@ import { useFirebaseAuth } from 'vuefire'
|
||||
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
@@ -110,15 +110,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isInitialized.value = true
|
||||
if (user === null) {
|
||||
lastTokenUserId.value = null
|
||||
|
||||
// Clear workspace sessionStorage on logout to prevent stale tokens
|
||||
try {
|
||||
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
|
||||
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||
} catch {
|
||||
// Ignore sessionStorage errors (e.g., in private browsing mode)
|
||||
}
|
||||
useWorkspaceAuthStore().clearWorkspaceContext()
|
||||
}
|
||||
|
||||
// Reset balance when auth state changes
|
||||
@@ -175,21 +167,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
*/
|
||||
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
||||
if (flags.teamWorkspacesEnabled) {
|
||||
const workspaceToken = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.TOKEN
|
||||
)
|
||||
const expiresAt = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
|
||||
)
|
||||
|
||||
if (workspaceToken && expiresAt) {
|
||||
const expiryTime = parseInt(expiresAt, 10)
|
||||
if (Date.now() < expiryTime) {
|
||||
return {
|
||||
Authorization: `Bearer ${workspaceToken}`
|
||||
}
|
||||
}
|
||||
}
|
||||
const wsHeader = useWorkspaceAuthStore().getWorkspaceAuthHeader()
|
||||
if (wsHeader) return wsHeader
|
||||
}
|
||||
|
||||
const token = await getIdToken()
|
||||
@@ -218,24 +197,29 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
*/
|
||||
const getAuthToken = async (): Promise<string | undefined> => {
|
||||
if (flags.teamWorkspacesEnabled) {
|
||||
const workspaceToken = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.TOKEN
|
||||
)
|
||||
const expiresAt = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
|
||||
)
|
||||
|
||||
if (workspaceToken && expiresAt) {
|
||||
const expiryTime = parseInt(expiresAt, 10)
|
||||
if (Date.now() < expiryTime) {
|
||||
return workspaceToken
|
||||
}
|
||||
}
|
||||
const wsToken = useWorkspaceAuthStore().getWorkspaceToken()
|
||||
if (wsToken) return wsToken
|
||||
}
|
||||
|
||||
return await getIdToken()
|
||||
}
|
||||
|
||||
const getAuthHeaderOrThrow = async (): Promise<AuthHeader> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
return authHeader
|
||||
}
|
||||
|
||||
const getFirebaseAuthHeaderOrThrow = async (): Promise<AuthHeader> => {
|
||||
const authHeader = await getFirebaseAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
return authHeader
|
||||
}
|
||||
|
||||
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
|
||||
isFetchingBalance.value = true
|
||||
try {
|
||||
@@ -538,7 +522,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
sendPasswordReset,
|
||||
updatePassword: _updatePassword,
|
||||
getAuthHeader,
|
||||
getAuthHeaderOrThrow,
|
||||
getFirebaseAuthHeader,
|
||||
getFirebaseAuthHeaderOrThrow,
|
||||
getAuthToken
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,12 +22,16 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerQueue', () => {
|
||||
const enqueueTaskMock = vi.fn()
|
||||
|
||||
return {
|
||||
useManagerQueue: () => ({
|
||||
statusMessage: ref(''),
|
||||
allTasksDone: ref(false),
|
||||
enqueueTask: enqueueTaskMock,
|
||||
isProcessingTasks: ref(false)
|
||||
}),
|
||||
useManagerQueue: () => {
|
||||
const isProcessing = ref(false)
|
||||
return {
|
||||
statusMessage: ref(''),
|
||||
allTasksDone: ref(false),
|
||||
enqueueTask: enqueueTaskMock,
|
||||
isProcessing,
|
||||
isProcessingTasks: isProcessing
|
||||
}
|
||||
},
|
||||
enqueueTask: enqueueTaskMock
|
||||
}
|
||||
})
|
||||
@@ -350,7 +354,7 @@ describe('useComfyManagerStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe.skip('isPackInstalling', () => {
|
||||
describe('isPackInstalling', () => {
|
||||
it('should return false for packs not being installed', () => {
|
||||
const store = useComfyManagerStore()
|
||||
expect(store.isPackInstalling('test-pack')).toBe(false)
|
||||
@@ -375,37 +379,6 @@ describe('useComfyManagerStore', () => {
|
||||
expect(store.isPackInstalling('test-pack')).toBe(true)
|
||||
})
|
||||
|
||||
it('should remove pack from installing list when explicitly removed', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
// Call installPack
|
||||
await store.installPack.call({
|
||||
id: 'test-pack',
|
||||
repository: 'https://github.com/test/test-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
|
||||
// Verify pack is installing
|
||||
expect(store.isPackInstalling('test-pack')).toBe(true)
|
||||
|
||||
// Call installPack again for another pack to demonstrate multiple installs
|
||||
await store.installPack.call({
|
||||
id: 'another-pack',
|
||||
repository: 'https://github.com/test/another-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
|
||||
// Both should be installing
|
||||
expect(store.isPackInstalling('test-pack')).toBe(true)
|
||||
expect(store.isPackInstalling('another-pack')).toBe(true)
|
||||
})
|
||||
|
||||
it('should track multiple packs installing independently', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
|
||||