Compare commits
26 Commits
pysssss/no
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99a7fc8c87 | ||
|
|
ff44443b5e | ||
|
|
407375af07 | ||
|
|
65f18d17af | ||
|
|
54a00aac75 | ||
|
|
d2358c83e8 | ||
|
|
b2f848893a | ||
|
|
5c0e15f403 | ||
|
|
dc09eb60e4 | ||
|
|
30b17407db | ||
|
|
5b4ebf4d99 | ||
|
|
6836419e96 | ||
|
|
4c59a5e424 | ||
|
|
82242f1b00 | ||
|
|
f9c334092c | ||
|
|
04aee0308b | ||
|
|
caa6f89436 | ||
|
|
c4d0b3c97a | ||
|
|
3eb7c29ea4 | ||
|
|
cc2cb7e89f | ||
|
|
d2f4d41960 | ||
|
|
070a5f59fe | ||
|
|
7864e780e7 | ||
|
|
db1257fdb3 | ||
|
|
7e7e2d5647 | ||
|
|
dabfc6521e |
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 |
|
||||
|
||||
42
browser_tests/assets/widgets/image_compare_widget.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageCompare",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 350],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "a_images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "b_images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCompare"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -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,84 +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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,9 +77,14 @@ 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'
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
|
||||
@@ -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,
|
||||
|
||||
78
browser_tests/helpers/builderTestUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
import { fitToViewInstant } from './fitToView'
|
||||
import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
|
||||
/**
|
||||
* Enter builder on the default workflow and select I/O.
|
||||
*
|
||||
* Loads the default workflow, optionally transforms it (e.g. convert a node
|
||||
* to subgraph), then enters builder mode and selects inputs + outputs.
|
||||
*
|
||||
* @param comfyPage - The page fixture.
|
||||
* @param getInputNode - Returns the node to click for input selection.
|
||||
* Receives the KSampler node ref and can transform the graph before
|
||||
* returning the target node. Defaults to using KSampler directly.
|
||||
* @returns The node used for input selection.
|
||||
*/
|
||||
export async function setupBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
|
||||
): Promise<NodeReference> {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.selectInputWidget(inputNode)
|
||||
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode()
|
||||
|
||||
return inputNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
export async function setupSubgraphBuilder(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeReference> {
|
||||
return setupBuilder(comfyPage, async (ksampler) => {
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
return subgraphNode
|
||||
})
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
export async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,89 +1,11 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Convert the KSampler (id 3) in the default workflow to a subgraph,
|
||||
* enter builder, select the promoted seed widget as input and
|
||||
* SaveImage/PreviewImage as output.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await subgraphNode.centerOnNode()
|
||||
|
||||
// Click the promoted seed widget on the canvas to select it
|
||||
const seedWidgetRef = await subgraphNode.getWidget(0)
|
||||
const seedPos = await seedWidgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
|
||||
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
await appMode.goToOutputs()
|
||||
|
||||
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()
|
||||
|
||||
// Node is centered on screen, so click the canvas center
|
||||
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()
|
||||
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
import {
|
||||
saveAndReopenInAppMode,
|
||||
setupSubgraphBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
|
||||
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -107,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)
|
||||
|
||||
@@ -130,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)
|
||||
|
||||
@@ -146,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)
|
||||
|
||||
@@ -166,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 () => {
|
||||
|
||||
371
browser_tests/tests/builderSaveFlow.spec.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import { setupBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
|
||||
const { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
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
|
||||
}) => {
|
||||
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 { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
await saveAs.nameInput.fill('')
|
||||
await expect(saveAs.saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
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 { footer } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
await expect(footer.backButton).toBeDisabled()
|
||||
await expect(footer.nextButton).toBeEnabled()
|
||||
|
||||
await footer.next()
|
||||
await expect(footer.backButton).toBeEnabled()
|
||||
|
||||
await footer.next()
|
||||
await expect(footer.nextButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Escape key exits builder mode', async ({ comfyPage }) => {
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
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 { footer, saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} direct-save`, 'App')
|
||||
await saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// 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 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 { footer, saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} split-btn`, 'App')
|
||||
await saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await footer.openSaveAsFromChevron()
|
||||
|
||||
await expect(saveAs.title).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.nameInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Connect output popover appears when no outputs selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByText('Connect an output', { exact: false })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('save as app produces correct extension and linearMode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-ext`, 'App')
|
||||
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain('.app.json')
|
||||
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(true)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
66
browser_tests/tests/changeTrackerLoadGuard.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
test.describe(
|
||||
'Change Tracker - isLoadingGraph guard',
|
||||
{ tag: '@workflow' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Prevents checkState from corrupting workflow state during tab switch', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Tab 0: default workflow (7 nodes)
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
|
||||
// Save tab 0 so it has a unique name for tab switching
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
|
||||
|
||||
// Register an extension that forces checkState during graph loading.
|
||||
// This simulates the bug scenario where a user clicks during graph loading
|
||||
// which triggers a checkState call on the wrong graph, corrupting the activeState.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.registerExtension({
|
||||
name: 'TestCheckStateDuringLoad',
|
||||
afterConfigureGraph() {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore)
|
||||
.workflow.activeWorkflow
|
||||
if (!workflow) throw new Error('No workflow found')
|
||||
// Bypass the guard to reproduce the corruption bug:
|
||||
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
|
||||
|
||||
// Simulate the user clicking during graph loading
|
||||
workflow.changeTracker.checkState()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Create tab 1: blank workflow (0 nodes)
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Switch back to tab 0 (workflow-a).
|
||||
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
|
||||
await tab0.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
|
||||
// switch to blank tab and back to verify no corruption
|
||||
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
|
||||
await tab1.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// switch again and verify no corruption
|
||||
await tab0.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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 |
81
browser_tests/tests/imageCompare.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Compare', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function createTestImageDataUrl(label: string, color: string): string {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">` +
|
||||
`<rect width="200" height="200" fill="${color}"/>` +
|
||||
`<text x="50%" y="50%" fill="white" font-size="24" ` +
|
||||
`text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
async function setImageCompareValue(
|
||||
comfyPage: ComfyPage,
|
||||
value: { beforeImages: string[]; afterImages: string[] }
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
},
|
||||
{ value }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test(
|
||||
'Shows empty state when no images are set',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node).toContainText('No images to compare')
|
||||
await expect(node.locator('img')).toHaveCount(0)
|
||||
await expect(node.locator('[role="presentation"]')).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Slider defaults to 50% with both images set',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(beforeImg).toBeVisible()
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
const handle = node.locator('[role="presentation"]')
|
||||
await expect(handle).toBeVisible()
|
||||
|
||||
expect(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
).toBe('50%')
|
||||
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
|
||||
|
||||
await expect(node).toHaveScreenshot('image-compare-default-50.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
After Width: | Height: | Size: 15 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'
|
||||
|
||||
92
browser_tests/tests/maskEditor.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Mask Editor', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
// Hover over the image panel to reveal action buttons
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible()
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'opens mask editor from context menu',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
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')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
await contextMenu.getByText('Open in Mask Editor').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot(
|
||||
'mask-editor-dialog-from-context-menu.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 321 KiB |
@@ -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
|
||||
}
|
||||
|
||||
114
browser_tests/tests/subgraphViewport.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
function hasVisibleNodeInViewport() {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas?.graph?._nodes?.length) return false
|
||||
|
||||
const ds = canvas.ds
|
||||
const cw = canvas.canvas.width / window.devicePixelRatio
|
||||
const ch = canvas.canvas.height / window.devicePixelRatio
|
||||
const visLeft = -ds.offset[0]
|
||||
const visTop = -ds.offset[1]
|
||||
const visRight = visLeft + cw / ds.scale
|
||||
const visBottom = visTop + ch / ds.scale
|
||||
|
||||
for (const node of canvas.graph._nodes) {
|
||||
const [nx, ny] = node.pos
|
||||
const [nw, nh] = node.size
|
||||
if (
|
||||
nx + nw > visLeft &&
|
||||
nx < visRight &&
|
||||
ny + nh > visTop &&
|
||||
ny < visBottom
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
test.describe('Subgraph viewport restoration', { tag: '@subgraph' }, () => {
|
||||
test('first visit fits viewport to subgraph nodes (LG)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph!
|
||||
const sgNode = graph._nodes.find((n) =>
|
||||
'isSubgraphNode' in n
|
||||
? (n as unknown as { isSubgraphNode: () => boolean }).isSubgraphNode()
|
||||
: false
|
||||
) as unknown as { subgraph?: typeof graph } | undefined
|
||||
if (!sgNode?.subgraph) throw new Error('No subgraph node')
|
||||
|
||||
canvas.setGraph(sgNode.subgraph)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('first visit fits viewport to subgraph nodes (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('viewport is restored when returning to root (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const rootViewport = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(rootViewport.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(rootViewport.offset[0], 0),
|
||||
expect.closeTo(rootViewport.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.7",
|
||||
"version": "1.43.8",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
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';
|
||||
@@ -28,44 +29,13 @@
|
||||
--text-2xs: 0.625rem;
|
||||
--text-2xs--line-height: calc(1 / 0.625);
|
||||
|
||||
--text-xxs: 0.625rem;
|
||||
--text-xxs--line-height: calc(1 / 0.625);
|
||||
|
||||
--text-xxxs: 0.5625rem;
|
||||
--text-xxxs--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;
|
||||
--text-3xs: 0.5625rem;
|
||||
--text-3xs--line-height: calc(1 / 0.5625);
|
||||
|
||||
--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;
|
||||
@@ -75,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;
|
||||
@@ -233,6 +195,7 @@
|
||||
--interface-builder-mode-background: var(--color-ocean-300);
|
||||
--interface-builder-mode-button-background: var(--color-ocean-600);
|
||||
--interface-builder-mode-button-foreground: var(--color-white);
|
||||
--interface-builder-mode-footer-background: var(--color-ocean-900);
|
||||
|
||||
--nav-background: var(--color-white);
|
||||
|
||||
@@ -376,6 +339,7 @@
|
||||
--interface-builder-mode-background: var(--color-ocean-900);
|
||||
--interface-builder-mode-button-background: var(--color-ocean-600);
|
||||
--interface-builder-mode-button-foreground: var(--color-white);
|
||||
--interface-builder-mode-footer-background: var(--color-ocean-900);
|
||||
|
||||
--nav-background: var(--color-charcoal-800);
|
||||
|
||||
@@ -519,6 +483,9 @@
|
||||
--color-interface-builder-mode-button-foreground: var(
|
||||
--interface-builder-mode-button-foreground
|
||||
);
|
||||
--color-interface-builder-mode-footer-background: var(
|
||||
--interface-builder-mode-footer-background
|
||||
);
|
||||
--color-interface-stroke: var(--interface-stroke);
|
||||
--color-nav-background: var(--nav-background);
|
||||
--color-node-border: var(--node-border);
|
||||
|
||||
@@ -16,6 +16,7 @@ export type {
|
||||
AssetCreated,
|
||||
AssetCreatedWritable,
|
||||
AssetDownloadResponse,
|
||||
AssetInfo,
|
||||
AssetMetadataResponse,
|
||||
AssetTagHistogramResponse,
|
||||
AssetUpdated,
|
||||
@@ -38,6 +39,11 @@ export type {
|
||||
CheckAssetByHashError,
|
||||
CheckAssetByHashErrors,
|
||||
CheckAssetByHashResponses,
|
||||
CheckHubUsernameData,
|
||||
CheckHubUsernameError,
|
||||
CheckHubUsernameErrors,
|
||||
CheckHubUsernameResponse,
|
||||
CheckHubUsernameResponses,
|
||||
ClaimInviteCodeData,
|
||||
ClaimInviteCodeError,
|
||||
ClaimInviteCodeErrors,
|
||||
@@ -62,7 +68,19 @@ export type {
|
||||
CreateDeletionRequestData,
|
||||
CreateDeletionRequestError,
|
||||
CreateDeletionRequestErrors,
|
||||
CreateDeletionRequestResponse,
|
||||
CreateDeletionRequestResponses,
|
||||
CreateHubAssetUploadUrlData,
|
||||
CreateHubAssetUploadUrlError,
|
||||
CreateHubAssetUploadUrlErrors,
|
||||
CreateHubAssetUploadUrlResponse,
|
||||
CreateHubAssetUploadUrlResponses,
|
||||
CreateHubProfileData,
|
||||
CreateHubProfileError,
|
||||
CreateHubProfileErrors,
|
||||
CreateHubProfileRequest,
|
||||
CreateHubProfileResponse,
|
||||
CreateHubProfileResponses,
|
||||
CreateInviteRequest,
|
||||
CreateSecretData,
|
||||
CreateSecretError,
|
||||
@@ -111,6 +129,11 @@ export type {
|
||||
DeleteAssetErrors,
|
||||
DeleteAssetResponse,
|
||||
DeleteAssetResponses,
|
||||
DeleteHubWorkflowData,
|
||||
DeleteHubWorkflowError,
|
||||
DeleteHubWorkflowErrors,
|
||||
DeleteHubWorkflowResponse,
|
||||
DeleteHubWorkflowResponses,
|
||||
DeleteSecretData,
|
||||
DeleteSecretError,
|
||||
DeleteSecretErrors,
|
||||
@@ -212,6 +235,16 @@ export type {
|
||||
GetGlobalSubgraphsErrors,
|
||||
GetGlobalSubgraphsResponse,
|
||||
GetGlobalSubgraphsResponses,
|
||||
GetHubProfileByUsernameData,
|
||||
GetHubProfileByUsernameError,
|
||||
GetHubProfileByUsernameErrors,
|
||||
GetHubProfileByUsernameResponse,
|
||||
GetHubProfileByUsernameResponses,
|
||||
GetHubWorkflowData,
|
||||
GetHubWorkflowError,
|
||||
GetHubWorkflowErrors,
|
||||
GetHubWorkflowResponse,
|
||||
GetHubWorkflowResponses,
|
||||
GetInviteCodeStatusData,
|
||||
GetInviteCodeStatusError,
|
||||
GetInviteCodeStatusErrors,
|
||||
@@ -250,11 +283,21 @@ export type {
|
||||
GetModelsInFolderErrors,
|
||||
GetModelsInFolderResponse,
|
||||
GetModelsInFolderResponses,
|
||||
GetMyHubProfileData,
|
||||
GetMyHubProfileError,
|
||||
GetMyHubProfileErrors,
|
||||
GetMyHubProfileResponse,
|
||||
GetMyHubProfileResponses,
|
||||
GetPaymentPortalData,
|
||||
GetPaymentPortalError,
|
||||
GetPaymentPortalErrors,
|
||||
GetPaymentPortalResponse,
|
||||
GetPaymentPortalResponses,
|
||||
GetPublishedWorkflowData,
|
||||
GetPublishedWorkflowError,
|
||||
GetPublishedWorkflowErrors,
|
||||
GetPublishedWorkflowResponse,
|
||||
GetPublishedWorkflowResponses,
|
||||
GetRawLogsData,
|
||||
GetRawLogsError,
|
||||
GetRawLogsErrors,
|
||||
@@ -305,11 +348,31 @@ export type {
|
||||
GetWorkspaceResponses,
|
||||
GlobalSubgraphData,
|
||||
GlobalSubgraphInfo,
|
||||
HubAssetUploadUrlRequest,
|
||||
HubAssetUploadUrlResponse,
|
||||
HubLabelInfo,
|
||||
HubLabelListResponse,
|
||||
HubProfile,
|
||||
HubProfileSummary,
|
||||
HubUsernameCheckResponse,
|
||||
HubWorkflowDetail,
|
||||
HubWorkflowListResponse,
|
||||
HubWorkflowStatus,
|
||||
HubWorkflowSummary,
|
||||
HubWorkflowTemplateEntry,
|
||||
ImportPublishedAssetsData,
|
||||
ImportPublishedAssetsError,
|
||||
ImportPublishedAssetsErrors,
|
||||
ImportPublishedAssetsRequest,
|
||||
ImportPublishedAssetsResponse,
|
||||
ImportPublishedAssetsResponse2,
|
||||
ImportPublishedAssetsResponses,
|
||||
InviteCodeClaimResponse,
|
||||
InviteCodeStatusResponse,
|
||||
JobStatusResponse,
|
||||
JwkKey,
|
||||
JwksResponse,
|
||||
LabelRef,
|
||||
LeaveWorkspaceData,
|
||||
LeaveWorkspaceError,
|
||||
LeaveWorkspaceErrors,
|
||||
@@ -322,6 +385,21 @@ export type {
|
||||
ListAssetsResponse2,
|
||||
ListAssetsResponses,
|
||||
ListAssetsResponseWritable,
|
||||
ListHubLabelsData,
|
||||
ListHubLabelsError,
|
||||
ListHubLabelsErrors,
|
||||
ListHubLabelsResponse,
|
||||
ListHubLabelsResponses,
|
||||
ListHubWorkflowIndexData,
|
||||
ListHubWorkflowIndexError,
|
||||
ListHubWorkflowIndexErrors,
|
||||
ListHubWorkflowIndexResponse,
|
||||
ListHubWorkflowIndexResponses,
|
||||
ListHubWorkflowsData,
|
||||
ListHubWorkflowsError,
|
||||
ListHubWorkflowsErrors,
|
||||
ListHubWorkflowsResponse,
|
||||
ListHubWorkflowsResponses,
|
||||
ListInvitesResponse,
|
||||
ListMembersResponse,
|
||||
ListSecretsData,
|
||||
@@ -376,6 +454,11 @@ export type {
|
||||
PlanAvailability,
|
||||
PlanAvailabilityReason,
|
||||
PlanSeatSummary,
|
||||
PostAssetsFromWorkflowData,
|
||||
PostAssetsFromWorkflowError,
|
||||
PostAssetsFromWorkflowErrors,
|
||||
PostAssetsFromWorkflowResponse,
|
||||
PostAssetsFromWorkflowResponses,
|
||||
PreviewPlanInfo,
|
||||
PreviewSubscribeData,
|
||||
PreviewSubscribeError,
|
||||
@@ -384,6 +467,13 @@ export type {
|
||||
PreviewSubscribeResponse,
|
||||
PreviewSubscribeResponse2,
|
||||
PreviewSubscribeResponses,
|
||||
PublishedWorkflowDetail,
|
||||
PublishHubWorkflowData,
|
||||
PublishHubWorkflowError,
|
||||
PublishHubWorkflowErrors,
|
||||
PublishHubWorkflowRequest,
|
||||
PublishHubWorkflowResponse,
|
||||
PublishHubWorkflowResponses,
|
||||
RawLogsResponse,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
@@ -421,6 +511,13 @@ export type {
|
||||
SendUserInviteEmailResponse,
|
||||
SendUserInviteEmailResponse2,
|
||||
SendUserInviteEmailResponses,
|
||||
SetReviewStatusData,
|
||||
SetReviewStatusError,
|
||||
SetReviewStatusErrors,
|
||||
SetReviewStatusRequest,
|
||||
SetReviewStatusResponse,
|
||||
SetReviewStatusResponse2,
|
||||
SetReviewStatusResponses,
|
||||
SubmitFeedbackData,
|
||||
SubmitFeedbackError,
|
||||
SubmitFeedbackErrors,
|
||||
@@ -455,6 +552,12 @@ export type {
|
||||
UpdateAssetTagsErrors,
|
||||
UpdateAssetTagsResponse,
|
||||
UpdateAssetTagsResponses,
|
||||
UpdateHubProfileData,
|
||||
UpdateHubProfileError,
|
||||
UpdateHubProfileErrors,
|
||||
UpdateHubProfileRequest,
|
||||
UpdateHubProfileResponse,
|
||||
UpdateHubProfileResponses,
|
||||
UpdateSecretData,
|
||||
UpdateSecretError,
|
||||
UpdateSecretErrors,
|
||||
@@ -486,6 +589,8 @@ export type {
|
||||
UserResponse,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
WorkflowApiAssetsRequest,
|
||||
WorkflowApiAssetsResponse,
|
||||
WorkflowForkedFrom,
|
||||
WorkflowListResponse,
|
||||
WorkflowResponse,
|
||||
|
||||
1082
packages/ingest-types/src/types.gen.ts
generated
441
packages/ingest-types/src/zod.gen.ts
generated
@@ -2,6 +2,220 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
export const zHubUsernameCheckResponse = z.object({
|
||||
username: z.string(),
|
||||
available: z.boolean(),
|
||||
suggestions: z.array(z.string()).optional(),
|
||||
validation_error: z.string().optional()
|
||||
})
|
||||
|
||||
export const zHubAssetUploadUrlResponse = z.object({
|
||||
upload_url: z.string(),
|
||||
public_url: z.string(),
|
||||
token: z.string()
|
||||
})
|
||||
|
||||
export const zHubAssetUploadUrlRequest = z.object({
|
||||
filename: z.string(),
|
||||
content_type: z.string()
|
||||
})
|
||||
|
||||
export const zPublishHubWorkflowRequest = z.object({
|
||||
username: z.string(),
|
||||
name: z.string(),
|
||||
workflow_filename: z.string(),
|
||||
asset_ids: z.array(z.string()),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
custom_nodes: z.array(z.string()).optional(),
|
||||
tutorial_url: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
|
||||
thumbnail_token_or_url: z.string().optional(),
|
||||
thumbnail_comparison_token_or_url: z.string().optional(),
|
||||
sample_image_tokens_or_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zAssetInfo = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
preview_url: z.string(),
|
||||
storage_url: z.string(),
|
||||
model: z.boolean(),
|
||||
public: z.boolean(),
|
||||
in_library: z.boolean()
|
||||
})
|
||||
|
||||
export const zHubProfileSummary = z.object({
|
||||
username: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
avatar_url: z.string().optional()
|
||||
})
|
||||
|
||||
export const zLabelRef = z.object({
|
||||
name: z.string(),
|
||||
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(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
thumbnail_comparison_url: z.string().optional(),
|
||||
models: z.array(zLabelRef).optional(),
|
||||
custom_nodes: z.array(zLabelRef).optional(),
|
||||
tutorial_url: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
sample_image_urls: z.array(z.string()).optional(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
workflow_json: z.record(z.unknown()),
|
||||
assets: z.array(zAssetInfo),
|
||||
profile: zHubProfileSummary
|
||||
})
|
||||
|
||||
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(),
|
||||
custom_nodes: z.array(zLabelRef).optional(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
thumbnail_comparison_url: z.string().optional(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
profile: zHubProfileSummary,
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
tutorial_url: z.string().optional(),
|
||||
sample_image_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zHubWorkflowListResponse = z.object({
|
||||
workflows: z.array(z.union([zHubWorkflowSummary, zHubWorkflowDetail])),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
export const zHubLabelInfo = z.object({
|
||||
name: z.string(),
|
||||
display_name: z.string(),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['tag', 'model', 'custom_node'])
|
||||
})
|
||||
|
||||
export const zHubLabelListResponse = z.object({
|
||||
labels: z.array(zHubLabelInfo)
|
||||
})
|
||||
|
||||
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(),
|
||||
requiresCustomNodes: z.array(z.string()).optional(),
|
||||
thumbnailVariant: z.string().optional(),
|
||||
mediaType: z.string().optional(),
|
||||
mediaSubtype: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
vram: z.number().optional(),
|
||||
openSource: z.boolean().optional(),
|
||||
profile: zHubProfileSummary.optional(),
|
||||
tutorialUrl: z.string().optional(),
|
||||
logos: z.array(z.record(z.unknown())).optional(),
|
||||
date: z.string().optional(),
|
||||
io: z
|
||||
.object({
|
||||
inputs: z.array(z.record(z.unknown())).optional(),
|
||||
outputs: z.array(z.record(z.unknown())).optional()
|
||||
})
|
||||
.optional(),
|
||||
includeOnDistributions: z.array(z.string()).optional(),
|
||||
thumbnailUrl: z.string().optional(),
|
||||
thumbnailComparisonUrl: z.string().optional(),
|
||||
shareId: z.string().optional(),
|
||||
extendedDescription: z.string().optional(),
|
||||
metaDescription: z.string().optional(),
|
||||
howToUse: z.array(z.string()).optional(),
|
||||
suggestedUseCases: z.array(z.string()).optional(),
|
||||
faqItems: z
|
||||
.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
answer: z.string()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
contentTemplate: z.string().optional()
|
||||
})
|
||||
|
||||
export const zUpdateHubProfileRequest = z.object({
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
avatar_token: z.string().nullish(),
|
||||
website_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zCreateHubProfileRequest = z.object({
|
||||
workspace_id: z.string(),
|
||||
username: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
avatar_token: z.string().optional(),
|
||||
website_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zHubProfile = z.object({
|
||||
username: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
avatar_url: z.string().optional(),
|
||||
website_urls: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const zImportPublishedAssetsResponse = z.object({
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zImportPublishedAssetsRequest = z.object({
|
||||
published_asset_ids: z.array(z.string())
|
||||
})
|
||||
|
||||
export const zPublishedWorkflowDetail = z.object({
|
||||
share_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
name: z.string(),
|
||||
listed: z.boolean(),
|
||||
publish_time: z.string().datetime().nullish(),
|
||||
workflow_json: z.record(z.unknown()),
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zWorkflowApiAssetsResponse = z.object({
|
||||
assets: z.array(zAssetInfo)
|
||||
})
|
||||
|
||||
export const zWorkflowApiAssetsRequest = z.object({
|
||||
workflow_api_json: z.record(z.unknown())
|
||||
})
|
||||
|
||||
export const zForkWorkflowRequest = z.object({
|
||||
source_version: z.number().int(),
|
||||
name: z.string().optional()
|
||||
@@ -669,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
|
||||
*/
|
||||
@@ -992,7 +1216,9 @@ export const zListAssetsData = z.object({
|
||||
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
|
||||
.optional(),
|
||||
order: z.enum(['asc', 'desc']).optional(),
|
||||
include_public: z.boolean().optional().default(true)
|
||||
job_ids: z.array(z.string().uuid()).optional(),
|
||||
include_public: z.boolean().optional().default(true),
|
||||
asset_hash: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -1234,6 +1460,28 @@ export const zCheckAssetByHashData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zPostAssetsFromWorkflowData = z.object({
|
||||
body: zWorkflowApiAssetsRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zPostAssetsFromWorkflowResponse = zWorkflowApiAssetsResponse
|
||||
|
||||
export const zImportPublishedAssetsData = z.object({
|
||||
body: zImportPublishedAssetsRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully imported assets
|
||||
*/
|
||||
export const zImportPublishedAssetsResponse2 = zImportPublishedAssetsResponse
|
||||
|
||||
export const zListSecretsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -1612,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(),
|
||||
@@ -1633,6 +1892,13 @@ export const zCreateDeletionRequestData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Created - deletion request created or already exists
|
||||
*/
|
||||
export const zCreateDeletionRequestResponse = z.object({
|
||||
user_found_in_cloud: z.boolean()
|
||||
})
|
||||
|
||||
export const zReportPartnerUsageData = z.object({
|
||||
body: zPartnerUsageRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -1928,3 +2194,176 @@ export const zForkWorkflowData = z.object({
|
||||
* Workflow forked successfully
|
||||
*/
|
||||
export const zForkWorkflowResponse = zWorkflowResponse
|
||||
|
||||
export const zCreateHubProfileData = z.object({
|
||||
body: zCreateHubProfileRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile created
|
||||
*/
|
||||
export const zCreateHubProfileResponse = zHubProfile
|
||||
|
||||
export const zGetMyHubProfileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile
|
||||
*/
|
||||
export const zGetMyHubProfileResponse = zHubProfile
|
||||
|
||||
export const zCheckHubUsernameData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
username: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Username availability result
|
||||
*/
|
||||
export const zCheckHubUsernameResponse = zHubUsernameCheckResponse
|
||||
|
||||
export const zGetHubProfileByUsernameData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
username: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile
|
||||
*/
|
||||
export const zGetHubProfileByUsernameResponse = zHubProfile
|
||||
|
||||
export const zUpdateHubProfileData = z.object({
|
||||
body: zUpdateHubProfileRequest,
|
||||
path: z.object({
|
||||
username: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub profile updated
|
||||
*/
|
||||
export const zUpdateHubProfileResponse = zHubProfile
|
||||
|
||||
export const zCreateHubAssetUploadUrlData = z.object({
|
||||
body: zHubAssetUploadUrlRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Presigned upload URL and token
|
||||
*/
|
||||
export const zCreateHubAssetUploadUrlResponse = zHubAssetUploadUrlResponse
|
||||
|
||||
export const zListHubLabelsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
type: z.enum(['tag', 'model', 'custom_node']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* List of labels
|
||||
*/
|
||||
export const zListHubLabelsResponse = zHubLabelListResponse
|
||||
|
||||
export const zListHubWorkflowsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().int().gte(1).lte(100).optional().default(20),
|
||||
search: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
detail: z.boolean().optional().default(false),
|
||||
status: z.array(zHubWorkflowStatus).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated list of hub workflows
|
||||
*/
|
||||
export const zListHubWorkflowsResponse = zHubWorkflowListResponse
|
||||
|
||||
export const zPublishHubWorkflowData = z.object({
|
||||
body: zPublishHubWorkflowRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow published to hub
|
||||
*/
|
||||
export const zPublishHubWorkflowResponse = zHubWorkflowDetail
|
||||
|
||||
export const zListHubWorkflowIndexData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
status: z.array(zHubWorkflowStatus).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* List of hub workflow template entries
|
||||
*/
|
||||
export const zListHubWorkflowIndexResponse = z.array(zHubWorkflowTemplateEntry)
|
||||
|
||||
export const zDeleteHubWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully unpublished
|
||||
*/
|
||||
export const zDeleteHubWorkflowResponse = z.void()
|
||||
|
||||
export const zGetHubWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Hub workflow detail
|
||||
*/
|
||||
export const zGetHubWorkflowResponse = zHubWorkflowDetail
|
||||
|
||||
export const zGetPublishedWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Published workflow details with asset statuses
|
||||
*/
|
||||
export const zGetPublishedWorkflowResponse = zPublishedWorkflowDetail
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<BuilderDialog @close="$emit('close')">
|
||||
<template #title>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ $t('builderToolbar.defaultModeAppliedTitle') }}
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--circle-check-big] size-4 text-green-500"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
appliedAsApp
|
||||
? $t('builderToolbar.defaultModeAppliedAppBody')
|
||||
: $t('builderToolbar.defaultModeAppliedGraphBody')
|
||||
}}
|
||||
</p>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
appliedAsApp
|
||||
? $t('builderToolbar.defaultModeAppliedAppPrompt')
|
||||
: $t('builderToolbar.defaultModeAppliedGraphPrompt')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="appliedAsApp">
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('viewApp')">
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('exitToWorkflow')">
|
||||
{{ $t('builderToolbar.exitToWorkflow') }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
defineProps<{
|
||||
appliedAsApp: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
viewApp: []
|
||||
close: []
|
||||
exitToWorkflow: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-80 w-full min-w-116 flex-col rounded-2xl bg-base-background"
|
||||
>
|
||||
<div class="flex w-full min-w-116 flex-col rounded-2xl bg-base-background">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
|
||||
@@ -11,11 +11,11 @@ import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
const mockExitBuilder = vi.hoisted(() => vi.fn())
|
||||
const mockShowDialog = vi.hoisted(() => vi.fn())
|
||||
const mockSave = vi.hoisted(() => vi.fn())
|
||||
const mockSaveAs = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockState = {
|
||||
mode: 'builder:select' as AppMode,
|
||||
settingView: false
|
||||
mode: 'builder:inputs' as AppMode
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
@@ -42,10 +42,37 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/builder/useAppSetDefaultView', () => ({
|
||||
useAppSetDefaultView: () => ({
|
||||
settingView: computed(() => mockState.settingView),
|
||||
showDialog: mockShowDialog
|
||||
const mockActiveWorkflow = ref<{
|
||||
isTemporary: boolean
|
||||
initialMode?: string
|
||||
isModified?: boolean
|
||||
changeTracker?: { checkState: () => void }
|
||||
} | null>({
|
||||
isTemporary: true,
|
||||
initialMode: 'app'
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: { extra: {} } }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./useBuilderSave', () => ({
|
||||
useBuilderSave: () => ({
|
||||
save: mockSave,
|
||||
saveAs: mockSaveAs,
|
||||
isSaving: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -55,7 +82,17 @@ const i18n = createI18n({
|
||||
messages: {
|
||||
en: {
|
||||
builderMenu: { exitAppBuilder: 'Exit app builder' },
|
||||
g: { back: 'Back', next: 'Next' }
|
||||
builderToolbar: {
|
||||
viewApp: 'View app',
|
||||
saveAs: 'Save as',
|
||||
app: 'App',
|
||||
nodeGraph: 'Node graph'
|
||||
},
|
||||
builderFooter: {
|
||||
opensAsApp: 'Open as an {mode}',
|
||||
opensAsGraph: 'Open as a {mode}'
|
||||
},
|
||||
g: { back: 'Back', next: 'Next', save: 'Save' }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -66,7 +103,7 @@ describe('BuilderFooterToolbar', () => {
|
||||
vi.clearAllMocks()
|
||||
mockState.mode = 'builder:inputs'
|
||||
mockHasOutputs.value = true
|
||||
mockState.settingView = false
|
||||
mockActiveWorkflow.value = { isTemporary: true, initialMode: 'app' }
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
@@ -75,7 +112,11 @@ describe('BuilderFooterToolbar', () => {
|
||||
render(BuilderFooterToolbar, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { Button: false }
|
||||
stubs: {
|
||||
Button: false,
|
||||
BuilderOpensAsPopover: true,
|
||||
ConnectOutputPopover: { template: '<div><slot /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -88,18 +129,12 @@ describe('BuilderFooterToolbar', () => {
|
||||
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables back on the second step', () => {
|
||||
it('enables back on the arrange step', () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
renderComponent()
|
||||
expect(screen.getByRole('button', { name: /back/i })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('disables next on the setDefaultView step', () => {
|
||||
mockState.settingView = true
|
||||
renderComponent()
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables next on arrange step when no outputs', () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
mockHasOutputs.value = false
|
||||
@@ -127,17 +162,55 @@ describe('BuilderFooterToolbar', () => {
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
|
||||
})
|
||||
|
||||
it('opens default view dialog on next click from arrange step', async () => {
|
||||
mockState.mode = 'builder:arrange'
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
|
||||
expect(mockShowDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls exitBuilder on exit button click', async () => {
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: /exit app builder/i }))
|
||||
expect(mockExitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls setMode app on view app click', async () => {
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: /view app/i }))
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('shows "Save as" when workflow is temporary', () => {
|
||||
mockActiveWorkflow.value = { isTemporary: true }
|
||||
renderComponent()
|
||||
expect(screen.getByRole('button', { name: 'Save as' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows "Save" when workflow is saved', () => {
|
||||
mockActiveWorkflow.value = { isTemporary: false }
|
||||
renderComponent()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls saveAs when workflow is temporary', async () => {
|
||||
mockActiveWorkflow.value = { isTemporary: true }
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: 'Save as' }))
|
||||
expect(mockSaveAs).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls save when workflow is saved and modified', async () => {
|
||||
mockActiveWorkflow.value = { isTemporary: false, isModified: true }
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(mockSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables save button when workflow has no unsaved changes', () => {
|
||||
mockActiveWorkflow.value = { isTemporary: false, isModified: false }
|
||||
renderComponent()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not call save when no outputs', async () => {
|
||||
mockHasOutputs.value = false
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: 'Save as' }))
|
||||
expect(mockSave).not.toHaveBeenCalled()
|
||||
expect(mockSaveAs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,46 +1,173 @@
|
||||
<template>
|
||||
<nav
|
||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
<div
|
||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 flex-col items-center"
|
||||
>
|
||||
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
:disabled="isFirstStep"
|
||||
@click="goBack"
|
||||
<!-- "Opens as" attachment tab -->
|
||||
<BuilderOpensAsPopover
|
||||
v-if="isSaved"
|
||||
:is-app-mode="isAppMode"
|
||||
@select="onSetDefaultView"
|
||||
/>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
|
||||
{{ t('g.back') }}
|
||||
</Button>
|
||||
<Button size="lg" :disabled="isLastStep" @click="goNext">
|
||||
{{ t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
|
||||
</Button>
|
||||
</nav>
|
||||
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="onViewApp">
|
||||
{{ t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
:disabled="isFirstStep"
|
||||
@click="goBack"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
|
||||
{{ t('g.back') }}
|
||||
</Button>
|
||||
<Button size="lg" :disabled="isLastStep" @click="goNext">
|
||||
{{ t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
|
||||
</Button>
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
:class="cn('w-24', disabledSaveClasses)"
|
||||
data-testid="builder-save-as-button"
|
||||
>
|
||||
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</ConnectOutputPopover>
|
||||
<ButtonGroup
|
||||
v-else-if="isSaved"
|
||||
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
:disabled="!isModified"
|
||||
class="flex-1"
|
||||
:class="isModified ? activeSaveClasses : disabledSaveClasses"
|
||||
data-testid="builder-save-button"
|
||||
@click="save()"
|
||||
>
|
||||
{{ t('g.save') }}
|
||||
</Button>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
size="lg"
|
||||
:aria-label="t('builderToolbar.saveAs')"
|
||||
data-save-chevron
|
||||
data-testid="builder-save-as-chevron"
|
||||
class="w-6 rounded-l-none border-l border-border-default px-0"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
|
||||
>
|
||||
<DropdownMenuItem as-child @select="saveAs()">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full justify-start font-normal"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</ButtonGroup>
|
||||
<Button
|
||||
v-else
|
||||
size="lg"
|
||||
:class="activeSaveClasses"
|
||||
data-testid="builder-save-as-button"
|
||||
@click="saveAs()"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
|
||||
import { setWorkflowDefaultView } from './builderViewOptions'
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode, setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
|
||||
const {
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSelectStep,
|
||||
navigateToStep,
|
||||
goBack,
|
||||
goNext
|
||||
} = useBuilderSteps({
|
||||
hasOutputs
|
||||
})
|
||||
const { save, saveAs } = useBuilderSave()
|
||||
|
||||
const isSaved = computed(
|
||||
() => workflowStore.activeWorkflow?.isTemporary === false
|
||||
)
|
||||
|
||||
const activeSaveClasses =
|
||||
'bg-interface-builder-mode-button-background text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/80'
|
||||
const disabledSaveClasses =
|
||||
'bg-secondary-background text-muted-foreground/50 disabled:opacity-100'
|
||||
|
||||
const isModified = computed(
|
||||
() => workflowStore.activeWorkflow?.isModified === true
|
||||
)
|
||||
|
||||
const isAppMode = computed(
|
||||
() => workflowStore.activeWorkflow?.initialMode !== 'graph'
|
||||
)
|
||||
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -60,4 +187,14 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
function onExitBuilder() {
|
||||
appModeStore.exitBuilder()
|
||||
}
|
||||
|
||||
function onViewApp() {
|
||||
setMode('app')
|
||||
}
|
||||
|
||||
function onSetDefaultView(openAsApp: boolean) {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
setWorkflowDefaultView(workflow, openAsApp)
|
||||
}
|
||||
</script>
|
||||
|
||||
84
src/components/builder/BuilderOpensAsPopover.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<PopoverRoot>
|
||||
<PopoverAnchor as-child>
|
||||
<div
|
||||
data-testid="builder-opens-as"
|
||||
class="flex h-8 min-w-64 items-center justify-center gap-2 rounded-t-2xl bg-interface-builder-mode-footer-background px-4 text-sm text-interface-builder-mode-button-foreground"
|
||||
>
|
||||
<i :class="cn(currentModeIcon, 'size-4')" aria-hidden="true" />
|
||||
<i18n-t
|
||||
:keypath="
|
||||
isAppMode
|
||||
? 'builderFooter.opensAsApp'
|
||||
: 'builderFooter.opensAsGraph'
|
||||
"
|
||||
tag="span"
|
||||
>
|
||||
<template #mode>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
class="-ml-0.5 h-6 gap-1 rounded-md border-none bg-transparent px-1.5 text-sm text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/70"
|
||||
>
|
||||
{{
|
||||
isAppMode
|
||||
? t('builderToolbar.app').toLowerCase()
|
||||
: t('builderToolbar.nodeGraph').toLowerCase()
|
||||
}}
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
|
||||
>
|
||||
<ViewTypeRadioGroup
|
||||
:model-value="isAppMode"
|
||||
:aria-label="t('builderToolbar.defaultViewLabel')"
|
||||
size="sm"
|
||||
@update:model-value="$emit('select', $event)"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
|
||||
|
||||
const { isAppMode } = defineProps<{
|
||||
isAppMode: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [openAsApp: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentModeIcon = computed(() =>
|
||||
isAppMode ? 'icon-[lucide--app-window]' : 'icon-[comfy--workflow]'
|
||||
)
|
||||
</script>
|
||||
71
src/components/builder/BuilderSaveDialogContent.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<BuilderDialog @close="emit('close')">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.saveAs') }}
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label :for="inputId" class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.filename') }}
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="filename"
|
||||
autofocus
|
||||
type="text"
|
||||
class="focus-visible:ring-ring flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground"
|
||||
@keydown.enter="
|
||||
filename.trim() && emit('save', filename.trim(), openAsApp)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label :id="radioGroupLabelId" class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.defaultViewLabel') }}
|
||||
</label>
|
||||
<ViewTypeRadioGroup
|
||||
v-model="openAsApp"
|
||||
:aria-labelledby="radioGroupLabelId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="muted-textonly" size="lg" @click="emit('close')">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!filename.trim()"
|
||||
@click="emit('save', filename.trim(), openAsApp)"
|
||||
>
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useId } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
|
||||
|
||||
const { defaultFilename, defaultOpenAsApp = true } = defineProps<{
|
||||
defaultFilename: string
|
||||
defaultOpenAsApp?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [filename: string, openAsApp: boolean]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const inputId = useId()
|
||||
const radioGroupLabelId = useId()
|
||||
const filename = ref(defaultFilename)
|
||||
const openAsApp = ref(defaultOpenAsApp)
|
||||
</script>
|
||||
@@ -23,55 +23,21 @@
|
||||
<StepLabel :step />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
|
||||
</template>
|
||||
|
||||
<!-- Default view -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'bg-transparent opacity-30')">
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
</ConnectOutputPopover>
|
||||
<button
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === 'setDefaultView'
|
||||
? 'bg-interface-builder-mode-background'
|
||||
: 'bg-transparent hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="navigateToStep('setDefaultView')"
|
||||
>
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="mx-1 h-px w-4 bg-border-default"
|
||||
role="separator"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
@@ -79,9 +45,7 @@ import type { BuilderStepId } from './useBuilderSteps'
|
||||
import { useBuilderSteps } from './useBuilderSteps'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
|
||||
const { activeStep, navigateToStep } = useBuilderSteps()
|
||||
|
||||
const stepClasses =
|
||||
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
|
||||
@@ -107,11 +71,5 @@ const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
icon: 'icon-[lucide--layout-panel-left]'
|
||||
}
|
||||
|
||||
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'setDefaultView',
|
||||
title: t('builderToolbar.defaultView'),
|
||||
subtitle: t('builderToolbar.defaultViewDescription'),
|
||||
icon: 'icon-[lucide--eye]'
|
||||
}
|
||||
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="8"
|
||||
align="end"
|
||||
:side-offset="18"
|
||||
:collision-padding="10"
|
||||
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-80 rounded-xl border border-border-default bg-base-background shadow-interface will-change-[transform,opacity]"
|
||||
>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<BuilderDialog @close="$emit('close')">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.defaultViewTitle') }}
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.defaultViewLabel') }}
|
||||
</label>
|
||||
<div role="radiogroup" class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-for="option in viewTypeOptions"
|
||||
:key="option.value.toString()"
|
||||
role="radio"
|
||||
:aria-checked="openAsApp === option.value"
|
||||
:class="
|
||||
cn(
|
||||
itemClasses,
|
||||
openAsApp === option.value && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
variant="textonly"
|
||||
@click="openAsApp = option.value"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(option.icon, 'size-4')" />
|
||||
</div>
|
||||
<div class="mx-2 flex flex-1 flex-col items-start">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ option.title }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ option.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="openAsApp === option.value"
|
||||
class="icon-[lucide--check] size-4 text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('apply', openAsApp)">
|
||||
{{ $t('g.apply') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { initialOpenAsApp = true } = defineProps<{
|
||||
initialOpenAsApp?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
apply: [openAsApp: boolean]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const openAsApp = ref(initialOpenAsApp)
|
||||
|
||||
const viewTypeOptions = [
|
||||
{
|
||||
value: true,
|
||||
icon: 'icon-[lucide--app-window]',
|
||||
title: t('builderToolbar.app'),
|
||||
subtitle: t('builderToolbar.appDescription')
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
title: t('builderToolbar.nodeGraph'),
|
||||
subtitle: t('builderToolbar.nodeGraphDescription')
|
||||
}
|
||||
]
|
||||
|
||||
const itemClasses =
|
||||
'flex h-14 cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background'
|
||||
</script>
|
||||
74
src/components/builder/ViewTypeRadioGroup.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div role="radiogroup" v-bind="$attrs" :class="cn('flex flex-col', gapClass)">
|
||||
<Button
|
||||
v-for="option in viewTypeOptions"
|
||||
:key="option.value.toString()"
|
||||
role="radio"
|
||||
:aria-checked="modelValue === option.value"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background',
|
||||
heightClass,
|
||||
modelValue === option.value && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
variant="textonly"
|
||||
@click="
|
||||
modelValue !== option.value && emit('update:modelValue', option.value)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(option.icon, 'size-4')" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="mx-2 flex flex-1 flex-col items-start">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ option.title }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ option.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="modelValue === option.value"
|
||||
class="icon-[lucide--check] size-4 text-base-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { size = 'md' } = defineProps<{
|
||||
modelValue: boolean
|
||||
size?: 'sm' | 'md'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const viewTypeOptions = [
|
||||
{
|
||||
value: true,
|
||||
icon: 'icon-[lucide--app-window]',
|
||||
title: t('builderToolbar.app'),
|
||||
subtitle: t('builderToolbar.appDescription')
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
title: t('builderToolbar.nodeGraph'),
|
||||
subtitle: t('builderToolbar.nodeGraphDescription')
|
||||
}
|
||||
]
|
||||
const heightClass = size === 'sm' ? 'h-12' : 'h-14'
|
||||
const gapClass = size === 'sm' ? 'gap-1' : 'gap-2'
|
||||
</script>
|
||||
70
src/components/builder/builderViewOptions.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMockLoadedWorkflow } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import type { setWorkflowDefaultView as SetWorkflowDefaultViewFn } from './builderViewOptions'
|
||||
|
||||
const mockTrackDefaultViewSet = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackDefaultViewSet: mockTrackDefaultViewSet })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: { extra: {} } }
|
||||
}))
|
||||
|
||||
describe('setWorkflowDefaultView', () => {
|
||||
let setWorkflowDefaultView: typeof SetWorkflowDefaultViewFn
|
||||
let app: { rootGraph: { extra: Record<string, unknown> } }
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./builderViewOptions')
|
||||
setWorkflowDefaultView = mod.setWorkflowDefaultView
|
||||
app = (await import('@/scripts/app')).app as typeof app
|
||||
app.rootGraph.extra = {}
|
||||
})
|
||||
|
||||
it('sets initialMode to app when openAsApp is true', () => {
|
||||
const workflow = createMockLoadedWorkflow({ initialMode: undefined })
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(workflow.initialMode).toBe('app')
|
||||
})
|
||||
|
||||
it('sets initialMode to graph when openAsApp is false', () => {
|
||||
const workflow = createMockLoadedWorkflow({ initialMode: undefined })
|
||||
setWorkflowDefaultView(workflow, false)
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
})
|
||||
|
||||
it('sets linearMode on rootGraph.extra', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(app.rootGraph.extra.linearMode).toBe(true)
|
||||
|
||||
setWorkflowDefaultView(workflow, false)
|
||||
expect(app.rootGraph.extra.linearMode).toBe(false)
|
||||
})
|
||||
|
||||
it('calls changeTracker.checkState', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tracks telemetry with correct default_view', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'app'
|
||||
})
|
||||
|
||||
setWorkflowDefaultView(workflow, false)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'graph'
|
||||
})
|
||||
})
|
||||
})
|
||||
16
src/components/builder/builderViewOptions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export function setWorkflowDefaultView(
|
||||
workflow: LoadedComfyWorkflow,
|
||||
openAsApp: boolean
|
||||
) {
|
||||
workflow.initialMode = openAsApp ? 'app' : 'graph'
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDialogService = vi.hoisted(() => ({
|
||||
showLayoutDialog: vi.fn()
|
||||
}))
|
||||
|
||||
const mockDialogStore = vi.hoisted(() => ({
|
||||
closeDialog: vi.fn(),
|
||||
isDialogOpen: vi.fn<(key: string) => boolean>().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: null as {
|
||||
initialMode?: string | null
|
||||
changeTracker?: { checkState: () => void }
|
||||
} | null
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
rootGraph: { extra: {} as Record<string, unknown> }
|
||||
}))
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockAppModeStore = vi.hoisted(() => ({
|
||||
exitBuilder: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => mockDialogService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => mockDialogStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: mockSetMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => mockAppModeStore
|
||||
}))
|
||||
|
||||
vi.mock('./DefaultViewDialogContent.vue', () => ({
|
||||
default: { name: 'MockDefaultViewDialogContent' }
|
||||
}))
|
||||
|
||||
vi.mock('./BuilderDefaultModeAppliedDialogContent.vue', () => ({
|
||||
default: { name: 'MockBuilderDefaultModeAppliedDialogContent' }
|
||||
}))
|
||||
|
||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||
|
||||
describe('useAppSetDefaultView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
mockApp.rootGraph.extra = {}
|
||||
})
|
||||
|
||||
describe('settingView', () => {
|
||||
it('reflects dialogStore.isDialogOpen', () => {
|
||||
mockDialogStore.isDialogOpen.mockReturnValue(true)
|
||||
const { settingView } = useAppSetDefaultView()
|
||||
expect(settingView.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showDialog', () => {
|
||||
it('opens dialog via dialogService', () => {
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp true when initialMode is not graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: 'app' }
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp false when initialMode is graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: 'graph' }
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(false)
|
||||
})
|
||||
|
||||
it('passes initialOpenAsApp true when no active workflow', () => {
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
expect(call.props.initialOpenAsApp).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleApply', () => {
|
||||
it('sets initialMode to app when openAsApp is true', () => {
|
||||
const workflow = { initialMode: null as string | null }
|
||||
mockWorkflowStore.activeWorkflow = workflow
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(workflow.initialMode).toBe('app')
|
||||
})
|
||||
|
||||
it('sets initialMode to graph when openAsApp is false', () => {
|
||||
const workflow = { initialMode: null as string | null }
|
||||
mockWorkflowStore.activeWorkflow = workflow
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(false)
|
||||
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
})
|
||||
|
||||
it('sets linearMode on rootGraph.extra', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockApp.rootGraph.extra.linearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('closes dialog after applying', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation dialog after applying', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(true)
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledTimes(2)
|
||||
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
expect(confirmCall.key).toBe('builder-default-view-applied')
|
||||
expect(confirmCall.props.appliedAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes appliedAsApp false to confirmation dialog when graph', () => {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
call.props.onApply(false)
|
||||
|
||||
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
expect(confirmCall.props.appliedAsApp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applied dialog', () => {
|
||||
function applyAndGetConfirmDialog(openAsApp: boolean) {
|
||||
mockWorkflowStore.activeWorkflow = { initialMode: null }
|
||||
|
||||
const { showDialog } = useAppSetDefaultView()
|
||||
showDialog()
|
||||
|
||||
const applyCall = mockDialogService.showLayoutDialog.mock.calls[0][0]
|
||||
applyCall.props.onApply(openAsApp)
|
||||
|
||||
return mockDialogService.showLayoutDialog.mock.calls[1][0]
|
||||
}
|
||||
|
||||
it('onViewApp sets mode to app and closes dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
confirmCall.props.onViewApp()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('onExitToWorkflow exits builder and closes dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
confirmCall.props.onExitToWorkflow()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
expect(mockAppModeStore.exitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('onClose closes confirmation dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
|
||||
mockDialogStore.closeDialog.mockClear()
|
||||
confirmCall.props.onClose()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
|
||||
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const DIALOG_KEY = 'builder-default-view'
|
||||
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
|
||||
|
||||
export function useAppSetDefaultView() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
|
||||
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
|
||||
|
||||
function showDialog() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: DefaultViewDialogContent,
|
||||
props: {
|
||||
initialOpenAsApp: workflowStore.activeWorkflow?.initialMode !== 'graph',
|
||||
onApply: handleApply,
|
||||
onClose: closeDialog
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleApply(openAsApp: boolean) {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
workflow.initialMode = openAsApp ? 'app' : 'graph'
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
|
||||
function showAppliedDialog(appliedAsApp: boolean) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: APPLIED_DIALOG_KEY,
|
||||
component: BuilderDefaultModeAppliedDialogContent,
|
||||
props: {
|
||||
appliedAsApp,
|
||||
onViewApp: () => {
|
||||
closeAppliedDialog()
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onExitToWorkflow: () => {
|
||||
closeAppliedDialog()
|
||||
appModeStore.exitBuilder()
|
||||
},
|
||||
onClose: closeAppliedDialog
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function closeAppliedDialog() {
|
||||
dialogStore.closeDialog({ key: APPLIED_DIALOG_KEY })
|
||||
}
|
||||
|
||||
return { settingView, showDialog }
|
||||
}
|
||||
341
src/components/builder/useBuilderSave.test.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
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>>()
|
||||
)
|
||||
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
|
||||
const mockShowConfirmDialog = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockExitBuilder = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockActiveWorkflow = ref<{
|
||||
filename: string
|
||||
initialMode?: string | null
|
||||
} | null>(null)
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: mockSetMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({ toastErrorHandler: mockToastErrorHandler })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackEnterLinear: mockTrackEnterLinear,
|
||||
trackDefaultViewSet: mockTrackDefaultViewSet
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
saveWorkflow: mockSaveWorkflow,
|
||||
saveWorkflowAs: mockSaveWorkflowAs
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({ showLayoutDialog: mockShowLayoutDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({ exitBuilder: mockExitBuilder })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ closeDialog: mockCloseDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
|
||||
showConfirmDialog: mockShowConfirmDialog
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params) return `${key}:${JSON.stringify(params)}`
|
||||
return key
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./BuilderSaveDialogContent.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
describe('useBuilderSave', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockActiveWorkflow.value = null
|
||||
})
|
||||
|
||||
describe('save()', () => {
|
||||
it('does nothing when there is no active workflow', async () => {
|
||||
const { save } = useBuilderSave()
|
||||
|
||||
await save()
|
||||
|
||||
expect(mockSaveWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves workflow directly without showing a dialog', async () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
mockSaveWorkflow.mockResolvedValueOnce(undefined)
|
||||
const { save } = useBuilderSave()
|
||||
|
||||
await save()
|
||||
|
||||
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
|
||||
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts error on failure', async () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const error = new Error('save failed')
|
||||
mockSaveWorkflow.mockRejectedValueOnce(error)
|
||||
const { save } = useBuilderSave()
|
||||
|
||||
await save()
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
|
||||
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents concurrent saves', async () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
let resolveSave!: () => void
|
||||
mockSaveWorkflow.mockReturnValueOnce(
|
||||
new Promise<void>((r) => {
|
||||
resolveSave = r
|
||||
})
|
||||
)
|
||||
const { save, isSaving } = useBuilderSave()
|
||||
|
||||
const firstSave = save()
|
||||
expect(isSaving.value).toBe(true)
|
||||
|
||||
await save()
|
||||
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
|
||||
|
||||
resolveSave()
|
||||
await firstSave
|
||||
expect(isSaving.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveAs()', () => {
|
||||
it('does nothing when there is no active workflow', () => {
|
||||
mockActiveWorkflow.value = null
|
||||
const { saveAs } = useBuilderSave()
|
||||
|
||||
saveAs()
|
||||
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens save dialog with correct defaultFilename and defaultOpenAsApp', () => {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const { saveAs } = useBuilderSave()
|
||||
|
||||
saveAs()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledOnce()
|
||||
const { key, props } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
expect(key).toBe(SAVE_DIALOG_KEY)
|
||||
expect(props.defaultFilename).toBe('my-workflow')
|
||||
expect(props.defaultOpenAsApp).toBe(true)
|
||||
})
|
||||
|
||||
it('passes defaultOpenAsApp: false when initialMode is graph', () => {
|
||||
mockActiveWorkflow.value = {
|
||||
filename: 'my-workflow',
|
||||
initialMode: 'graph'
|
||||
}
|
||||
const { saveAs } = useBuilderSave()
|
||||
|
||||
saveAs()
|
||||
|
||||
const { props } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
expect(props.defaultOpenAsApp).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('save dialog callbacks', () => {
|
||||
function getSaveDialogProps() {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
const { saveAs } = useBuilderSave()
|
||||
saveAs()
|
||||
return mockShowLayoutDialog.mock.calls[0][0].props as {
|
||||
onSave: (filename: string, openAsApp: boolean) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
}
|
||||
|
||||
it('onSave calls saveWorkflowAs with isApp and tracks telemetry', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
{
|
||||
filename: 'new-name',
|
||||
isApp: true
|
||||
}
|
||||
)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'app'
|
||||
})
|
||||
})
|
||||
|
||||
it('onSave passes isApp: false when saving as graph', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
|
||||
mockActiveWorkflow.value,
|
||||
{
|
||||
filename: 'new-name',
|
||||
isApp: false
|
||||
}
|
||||
)
|
||||
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
|
||||
default_view: 'graph'
|
||||
})
|
||||
})
|
||||
|
||||
it('onSave does not track or close when saveWorkflowAs returns falsy', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(null)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockTrackDefaultViewSet).not.toHaveBeenCalled()
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onSave closes dialog and shows success dialog after successful save', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
|
||||
expect(mockShowConfirmDialog).toHaveBeenCalledOnce()
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.key).toBe(SUCCESS_DIALOG_KEY)
|
||||
})
|
||||
|
||||
it('shows app success message when openAsApp is true', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', true)
|
||||
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.props.promptText).toBe('builderSave.successBodyApp')
|
||||
})
|
||||
|
||||
it('shows graph success message with exit builder button when openAsApp is false', async () => {
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
const successCall = mockShowConfirmDialog.mock.calls[0][0]
|
||||
expect(successCall.props.promptText).toBe('builderSave.successBodyGraph')
|
||||
expect(successCall.footerProps.confirmText).toBe(
|
||||
'linearMode.builder.exit'
|
||||
)
|
||||
expect(successCall.footerProps.cancelText).toBe('builderToolbar.viewApp')
|
||||
})
|
||||
|
||||
it('onSave toasts error and closes dialog on failure', async () => {
|
||||
const error = new Error('save-as failed')
|
||||
mockSaveWorkflowAs.mockRejectedValueOnce(error)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
await onSave('new-name', false)
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
|
||||
})
|
||||
|
||||
it('prevents concurrent handleSaveAs calls', async () => {
|
||||
let resolveSaveAs!: (v: boolean) => void
|
||||
mockSaveWorkflowAs.mockReturnValueOnce(
|
||||
new Promise<boolean>((r) => {
|
||||
resolveSaveAs = r
|
||||
})
|
||||
)
|
||||
const { onSave } = getSaveDialogProps()
|
||||
|
||||
const firstSave = onSave('new-name', true)
|
||||
|
||||
await onSave('other-name', true)
|
||||
expect(mockSaveWorkflowAs).toHaveBeenCalledOnce()
|
||||
|
||||
resolveSaveAs(true)
|
||||
await firstSave
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph success dialog callbacks', () => {
|
||||
async function getGraphSuccessDialogProps() {
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
|
||||
mockSaveWorkflowAs.mockResolvedValueOnce(true)
|
||||
const { saveAs } = useBuilderSave()
|
||||
saveAs()
|
||||
const { onSave } = mockShowLayoutDialog.mock.calls[0][0].props as {
|
||||
onSave: (filename: string, openAsApp: boolean) => Promise<void>
|
||||
}
|
||||
await onSave('new-name', false)
|
||||
return mockShowConfirmDialog.mock.calls[0][0].footerProps as {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
}
|
||||
|
||||
it('onConfirm closes dialog and exits builder', async () => {
|
||||
const { onConfirm } = await getGraphSuccessDialogProps()
|
||||
|
||||
onConfirm()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
|
||||
expect(mockExitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('onCancel closes dialog and switches to app mode', async () => {
|
||||
const { onCancel } = await getGraphSuccessDialogProps()
|
||||
|
||||
onCancel()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
|
||||
expect(mockTrackEnterLinear).toHaveBeenCalledWith({
|
||||
source: 'app_builder'
|
||||
})
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
})
|
||||
})
|
||||
135
src/components/builder/useBuilderSave.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
const isSaving = ref(false)
|
||||
|
||||
export function useBuilderSave() {
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { setMode } = useAppMode()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function closeDialog(key: string) {
|
||||
dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveAs() {
|
||||
if (isSaving.value) return
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: SAVE_DIALOG_KEY,
|
||||
component: BuilderSaveDialogContent,
|
||||
props: {
|
||||
defaultFilename: workflow.filename,
|
||||
defaultOpenAsApp: workflow.initialMode !== 'graph',
|
||||
onSave: handleSaveAs,
|
||||
onClose: () => closeDialog(SAVE_DIALOG_KEY)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveAs(filename: string, openAsApp: boolean) {
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename,
|
||||
isApp: openAsApp
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog(SAVE_DIALOG_KEY)
|
||||
showSuccessDialog(openAsApp ? 'app' : 'graph')
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
closeDialog(SAVE_DIALOG_KEY)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessDialog(viewType: 'app' | 'graph') {
|
||||
const promptText =
|
||||
viewType === 'app'
|
||||
? t('builderSave.successBodyApp')
|
||||
: t('builderSave.successBodyGraph')
|
||||
|
||||
showConfirmDialog({
|
||||
key: SUCCESS_DIALOG_KEY,
|
||||
headerProps: {
|
||||
title: t('builderSave.successTitle'),
|
||||
icon: 'icon-[lucide--circle-check-big] text-green-500'
|
||||
},
|
||||
props: { promptText, preserveNewlines: true },
|
||||
footerProps:
|
||||
viewType === 'graph'
|
||||
? {
|
||||
cancelText: t('builderToolbar.viewApp'),
|
||||
confirmText: t('linearMode.builder.exit'),
|
||||
confirmVariant: 'secondary' as const,
|
||||
onCancel: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onConfirm: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
appModeStore.exitBuilder()
|
||||
}
|
||||
}
|
||||
: {
|
||||
cancelText: t('g.close'),
|
||||
confirmText: t('builderToolbar.viewApp'),
|
||||
confirmVariant: 'secondary' as const,
|
||||
onCancel: () => closeDialog(SUCCESS_DIALOG_KEY),
|
||||
onConfirm: () => {
|
||||
closeDialog(SUCCESS_DIALOG_KEY)
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { save, saveAs, isSaving }
|
||||
}
|
||||
@@ -4,13 +4,10 @@ import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||
|
||||
const BUILDER_STEPS = [
|
||||
'builder:inputs',
|
||||
'builder:outputs',
|
||||
'builder:arrange',
|
||||
'setDefaultView'
|
||||
'builder:arrange'
|
||||
] as const
|
||||
|
||||
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
|
||||
@@ -19,10 +16,8 @@ const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
|
||||
|
||||
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
const { mode, isBuilderMode, setMode } = useAppMode()
|
||||
const { settingView, showDialog } = useAppSetDefaultView()
|
||||
|
||||
const activeStep = computed<BuilderStepId>(() => {
|
||||
if (settingView.value) return 'setDefaultView'
|
||||
if (isBuilderMode.value) {
|
||||
return mode.value as BuilderStepId
|
||||
}
|
||||
@@ -47,23 +42,14 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
activeStep.value === 'builder:outputs'
|
||||
)
|
||||
|
||||
function navigateToStep(stepId: BuilderStepId) {
|
||||
if (stepId === 'setDefaultView') {
|
||||
setMode('builder:arrange')
|
||||
showDialog()
|
||||
} else {
|
||||
setMode(stepId)
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (isFirstStep.value) return
|
||||
navigateToStep(BUILDER_STEPS[activeStepIndex.value - 1])
|
||||
setMode(BUILDER_STEPS[activeStepIndex.value - 1])
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (isLastStep.value) return
|
||||
navigateToStep(BUILDER_STEPS[activeStepIndex.value + 1])
|
||||
setMode(BUILDER_STEPS[activeStepIndex.value + 1])
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -72,7 +58,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSelectStep,
|
||||
navigateToStep,
|
||||
navigateToStep: setMode,
|
||||
goBack,
|
||||
goNext
|
||||
}
|
||||
|
||||
116
src/components/common/Badge.stories.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Badge from './Badge.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Badges/Badge',
|
||||
component: Badge,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
severity: {
|
||||
control: 'select',
|
||||
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['label', 'dot', 'circle']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
label: 'NEW',
|
||||
severity: 'default'
|
||||
}
|
||||
} satisfies Meta<typeof Badge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
label: 'NEW',
|
||||
severity: 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
export const Warn: Story = {
|
||||
args: {
|
||||
label: 'NEW',
|
||||
severity: 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
label: 'NEW',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Contrast: Story = {
|
||||
args: {
|
||||
label: 'NEW',
|
||||
severity: 'contrast'
|
||||
}
|
||||
}
|
||||
|
||||
export const Circle: Story = {
|
||||
args: {
|
||||
label: '3',
|
||||
variant: 'circle'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllSeveritiesLabel: Story = {
|
||||
render: () => ({
|
||||
components: { Badge },
|
||||
template: `
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge label="NEW" severity="default" />
|
||||
<Badge label="NEW" severity="secondary" />
|
||||
<Badge label="NEW" severity="warn" />
|
||||
<Badge label="NEW" severity="danger" />
|
||||
<Badge label="NEW" severity="contrast" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSeveritiesDot: Story = {
|
||||
render: () => ({
|
||||
components: { Badge },
|
||||
template: `
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="dot" severity="default" />
|
||||
<Badge variant="dot" severity="secondary" />
|
||||
<Badge variant="dot" severity="warn" />
|
||||
<Badge variant="dot" severity="danger" />
|
||||
<Badge variant="dot" severity="contrast" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { Badge },
|
||||
template: `
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<Badge label="NEW" variant="label" />
|
||||
<span class="text-xs text-muted-foreground">label</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<Badge variant="dot" severity="danger" />
|
||||
<span class="text-xs text-muted-foreground">dot</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<Badge label="5" variant="circle" />
|
||||
<span class="text-xs text-muted-foreground">circle</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
69
src/components/common/Badge.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Badge from './Badge.vue'
|
||||
import { badgeVariants } from './badge.variants'
|
||||
|
||||
describe('Badge', () => {
|
||||
it('renders label text', () => {
|
||||
const wrapper = mount(Badge, { props: { label: 'NEW' } })
|
||||
expect(wrapper.text()).toBe('NEW')
|
||||
})
|
||||
|
||||
it('renders numeric label', () => {
|
||||
const wrapper = mount(Badge, { props: { label: 5 } })
|
||||
expect(wrapper.text()).toBe('5')
|
||||
})
|
||||
|
||||
it('defaults to dot variant when no label is provided', () => {
|
||||
const wrapper = mount(Badge)
|
||||
expect(wrapper.classes()).toContain('size-2')
|
||||
})
|
||||
|
||||
it('defaults to label variant when label is provided', () => {
|
||||
const wrapper = mount(Badge, { props: { label: 'NEW' } })
|
||||
expect(wrapper.classes()).toContain('font-semibold')
|
||||
expect(wrapper.classes()).toContain('uppercase')
|
||||
})
|
||||
|
||||
it('applies circle variant', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { label: '3', variant: 'circle' }
|
||||
})
|
||||
expect(wrapper.classes()).toContain('size-3.5')
|
||||
})
|
||||
|
||||
it('merges custom class via cn()', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { label: 'Test', class: 'ml-2' }
|
||||
})
|
||||
expect(wrapper.classes()).toContain('ml-2')
|
||||
expect(wrapper.classes()).toContain('rounded-full')
|
||||
})
|
||||
|
||||
describe('twMerge preserves color alongside text-3xs font size', () => {
|
||||
it.each([
|
||||
['default', 'text-white'],
|
||||
['secondary', 'text-white'],
|
||||
['warn', 'text-white'],
|
||||
['danger', 'text-white'],
|
||||
['contrast', 'text-base-background']
|
||||
] as const)(
|
||||
'%s severity retains its text color class',
|
||||
(severity, expectedColor) => {
|
||||
const classes = badgeVariants({ severity, variant: 'label' })
|
||||
expect(classes).toContain(expectedColor)
|
||||
expect(classes).toContain('text-3xs')
|
||||
}
|
||||
)
|
||||
|
||||
it('cn() does not clobber text-white when merging with text-3xs', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { label: 'Test', severity: 'danger' }
|
||||
})
|
||||
const classList = wrapper.classes()
|
||||
expect(classList).toContain('text-white')
|
||||
expect(classList).toContain('text-3xs')
|
||||
})
|
||||
})
|
||||
})
|
||||
36
src/components/common/Badge.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { badgeVariants } from './badge.variants'
|
||||
import type { BadgeVariants } from './badge.variants'
|
||||
|
||||
const {
|
||||
label,
|
||||
severity = 'default',
|
||||
variant,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
label?: string | number
|
||||
severity?: BadgeVariants['severity']
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const badgeClass = computed(() =>
|
||||
cn(
|
||||
badgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
}),
|
||||
className
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeClass">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -3,7 +3,7 @@
|
||||
data-testid="badge-pill"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-xxs',
|
||||
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-2xs',
|
||||
textColorClass
|
||||
)
|
||||
"
|
||||
|
||||
@@ -59,7 +59,7 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
v-else-if="item.new"
|
||||
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-2xs leading-none font-bold"
|
||||
v-text="t('contextMenu.new')"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Common/StatusBadge',
|
||||
component: StatusBadge,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
severity: {
|
||||
control: 'select',
|
||||
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['label', 'dot', 'circle']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
label: 'Status',
|
||||
severity: 'default'
|
||||
}
|
||||
} satisfies Meta<typeof StatusBadge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
label: 'Failed',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Finished: Story = {
|
||||
args: {
|
||||
label: 'Finished',
|
||||
severity: 'contrast'
|
||||
}
|
||||
}
|
||||
|
||||
export const Dot: Story = {
|
||||
args: {
|
||||
label: undefined,
|
||||
variant: 'dot',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Circle: Story = {
|
||||
args: {
|
||||
label: '3',
|
||||
variant: 'circle'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllSeverities: Story = {
|
||||
render: () => ({
|
||||
components: { StatusBadge },
|
||||
template: `
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge label="Default" severity="default" />
|
||||
<StatusBadge label="Secondary" severity="secondary" />
|
||||
<StatusBadge label="Warn" severity="warn" />
|
||||
<StatusBadge label="Danger" severity="danger" />
|
||||
<StatusBadge label="Contrast" severity="contrast" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { StatusBadge },
|
||||
template: `
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge label="Label" variant="label" />
|
||||
<span class="text-xs text-muted">label</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge variant="dot" severity="danger" />
|
||||
<span class="text-xs text-muted">dot</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge label="5" variant="circle" />
|
||||
<span class="text-xs text-muted">circle</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -44,7 +44,7 @@ const {
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-xxs text-base-foreground uppercase"
|
||||
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-2xs text-base-foreground uppercase"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
|
||||
26
src/components/common/badge.variants.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const badgeVariants = cva({
|
||||
base: 'inline-flex items-center justify-center rounded-full',
|
||||
variants: {
|
||||
severity: {
|
||||
default: 'bg-primary-background text-white',
|
||||
secondary: 'bg-secondary-background-hover text-white',
|
||||
warn: 'bg-warning-background text-white',
|
||||
danger: 'bg-destructive-background text-white',
|
||||
contrast: 'bg-base-foreground text-base-background'
|
||||
},
|
||||
variant: {
|
||||
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
|
||||
dot: 'size-2',
|
||||
circle: 'size-3.5 text-3xs font-semibold'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
severity: 'default',
|
||||
variant: 'label'
|
||||
}
|
||||
})
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
@@ -12,9 +12,9 @@ export const statusBadgeVariants = cva({
|
||||
contrast: 'bg-base-foreground text-base-background'
|
||||
},
|
||||
variant: {
|
||||
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
|
||||
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
|
||||
dot: 'size-2',
|
||||
circle: 'size-3.5 text-xxxs font-semibold'
|
||||
circle: 'size-3.5 text-3xs font-semibold'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
<div
|
||||
class="flex items-center gap-2 p-4 font-inter text-sm font-bold text-base-foreground"
|
||||
>
|
||||
<i v-if="icon" :class="cn(icon, 'size-4')" aria-hidden="true" />
|
||||
<span v-if="title" class="flex-auto">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
title?: string
|
||||
icon?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||