Compare commits

..

7 Commits

Author SHA1 Message Date
Comfy Org PR Bot
daeae316b1 [backport cloud/1.45] Fix interrupted audio playback from assets panel (#12525)
Backport of #12425 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-29 12:39:16 -07:00
Comfy Org PR Bot
20cf8074a9 [backport cloud/1.45] Fix ghost links on IO remove slot (#12523)
Backport of #12473 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-29 10:38:30 -07:00
Comfy Org PR Bot
c87cb03024 [backport cloud/1.45] Fix restoring values to dynamic combos (#12490)
Backport of #12211 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 11:39:14 -07:00
Comfy Org PR Bot
28c4080134 [backport cloud/1.45] Fix mask editor sometimes showing wrong image (#12484)
Backport of #12413 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 00:47:08 -07:00
Comfy Org PR Bot
1a6e77e955 [backport cloud/1.45] Fix errant subscription popups with workspaces (#12476)
Backport of #12472 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-26 19:42:39 -07:00
Comfy Org PR Bot
e06d7a7b34 [backport cloud/1.45] fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12454)
Backport of #12447 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-05-25 21:52:32 -07:00
Comfy Org PR Bot
c76b7280af [backport cloud/1.45] Fix missing value control on 'Primitive Int' (#12462)
Backport of #12431 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-25 21:22:28 -07:00
347 changed files with 11241 additions and 27254 deletions

View File

@@ -32,12 +32,12 @@
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",

View File

@@ -139,13 +139,13 @@ for PR in ${CONFLICT_PRS[@]}; do
# ───────────────────────────────────────────────────────────────────────
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
# Guard each targeted command against empty file lists — running `pnpm test:unit`
# with no path filter would run the full suite, and `pnpm exec eslint` with no args errors.
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
pnpm typecheck
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
if [ ${#TEST_FILES[@]} -gt 0 ]; then
pnpm test:unit "${TEST_FILES[@]}"
pnpm test:unit -- run "${TEST_FILES[@]}"
else
echo "No changed test files — skipping targeted unit tests"
fi
@@ -368,7 +368,7 @@ Cherry-picked from upstream merge commit `SHORT_SHA`.
## Validation
- `pnpm typecheck`
- `pnpm test:unit <targeted suites>` ✅ (N/N passing)
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
- `pnpm exec eslint <changed files>` ✅ (0 errors)
- `pnpm exec oxfmt --check` ✅ (clean)

View File

@@ -95,7 +95,7 @@ Run the test locally before pushing to confirm it fails for the right reason:
```bash
# Vitest
pnpm test:unit <test-file>
pnpm test:unit -- <test-file>
# Playwright
pnpm test:browser:local -- --grep "<test name>"

View File

@@ -169,7 +169,7 @@ expect(result).toBeDefined() // This proves nothing
```bash
# Instead of fixing the code, just updating the snapshot to match buggy output
pnpm test:unit --update
pnpm test:unit -- --update
```
If a snapshot needs updating, the fix should change the code behavior, not the expected output.

View File

@@ -1,24 +0,0 @@
name: Detect Unreviewed Merge
# SOC 2 compliance — reusable workflow lives in Comfy-Org/github-workflows,
# tracking issues are filed in Comfy-Org/unreviewed-merges.
on:
push:
branches: [main, master]
concurrency:
group: detect-unreviewed-merge-${{ github.sha }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: read
jobs:
detect:
uses: Comfy-Org/github-workflows/.github/workflows/detect-unreviewed-merge.yml@4d9cb6b87f953bb7cd69954280e1465fb9bd2040 # v1
with:
approval-mode: latest-per-reviewer
secrets:
UNREVIEWED_MERGES_TOKEN: ${{ secrets.UNREVIEWED_MERGES_TOKEN }}

2
.nvmrc
View File

@@ -1 +1 @@
25
24

View File

@@ -307,20 +307,6 @@ When referencing Comfy-Org repos:
- NEVER use `--no-verify` flag when committing
- NEVER delete or disable tests to make them pass
- NEVER circumvent quality checks
- NEVER add multi-line block comments to justify trivial code changes
- A one-line fix does not need a three-line comment explaining why
- A guard clause that mirrors another file does not need a comment naming that file
- A test setup line does not need a comment paraphrasing what the next line does
- If the diff is small and obvious, the comment is noise — write the code and move on
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
- **Penance protocol when you catch yourself adding one of these comments:**
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
- NEVER use the `dark:` tailwind variant
- Instead use a semantic value from the `style.css` theme
- e.g. `bg-node-component-surface`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -1,4 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,11 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3062_2148)">
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
</g>
<defs>
<clipPath id="clip0_3062_2148">
<rect width="48" height="48" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,4 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<rect width="48" height="48" rx="12" fill="#211927"/>
<path fill="#F2FF59" d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z"/>
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: #000000; }
.fg { fill: #F2FF59; }
@media (prefers-color-scheme: dark) {
.bg { fill: #F2FF59; }
.fg { fill: #000000; }
}
</style>
<circle class="bg" cx="24" cy="24" r="24"/>
<g transform="translate(7.8 6.72) scale(0.72)">
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32.0001 0C14.3391 0 0 14.3369 0 32.0001C0 49.6633 14.318 64.0001 32.0001 64.0001C49.6822 64.0001 64.0001 49.6842 64.0001 32.0001C64.0001 14.3158 49.6822 0 32.0001 0ZM19.3431 19.3685H37.5927L34.8175 23.8105H16.5677L19.3431 19.3685ZM49.8504 41.5369L47.075 37.1159H38.9804L41.7556 32.6737H44.3207L41.2301 27.7264L32.6097 41.5369H9.5874L15.138 32.6737H11.0592L13.8345 28.2317H31.6216L28.8462 32.6737H20.3522L17.5769 37.1159H30.1289L41.2091 19.3685L55.0646 41.558H49.8293L49.8504 41.5369Z" fill="#4D3762"/>
</svg>

After

Width:  |  Height:  |  Size: 615 B

View File

@@ -1,23 +0,0 @@
{
"name": "Comfy",
"short_name": "Comfy",
"id": "/",
"start_url": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"theme_color": "#211927",
"background_color": "#211927",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -87,8 +87,8 @@ function scrollToDepartment(deptKey: string) {
<template>
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
<div class="mx-auto max-w-6xl">
<div class="flex flex-col gap-12 lg:flex-row lg:gap-20">
<div class="shrink-0 lg:min-w-48">
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
<div class="shrink-0 md:w-48">
<div
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
>
@@ -133,41 +133,30 @@ function scrollToDepartment(deptKey: string) {
:href="role.jobUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 hover:border-primary-comfy-canvas group flex items-center gap-4 border-b py-5 transition-colors duration-200"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
data-testid="careers-role-link"
>
<div
class="flex min-w-0 flex-1 flex-col md:flex-row md:items-baseline md:gap-x-4"
>
<div class="min-w-0">
<span
class="text-primary-comfy-canvas text-base font-medium md:text-lg"
>
{{ role.title }}
</span>
<div
class="text-primary-warm-gray mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm md:mt-0 md:contents"
>
<span>{{ role.department }}</span>
<span class="md:hidden">{{ role.location }}</span>
</div>
<span class="text-primary-warm-gray ml-3 text-sm">
{{ role.department }}
</span>
</div>
<span
class="text-primary-warm-gray hidden shrink-0 text-sm md:inline"
>
{{ role.location }}
</span>
<span
class="bg-primary-comfy-yellow/0 group-hover:bg-primary-comfy-yellow relative grid size-7 shrink-0 place-items-center rounded-sm transition-colors duration-300 ease-out"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors duration-300 ease-out"
style="
mask: url('/icons/arrow-up-right.svg') center / contain
no-repeat;
"
<div class="ml-4 flex shrink-0 items-center gap-3">
<span class="text-primary-warm-gray text-sm">
{{ role.location }}
</span>
<img
src="/icons/arrow-up-right.svg"
alt=""
class="size-5"
aria-hidden="true"
/>
</span>
</div>
</a>
</div>
</div>

View File

@@ -18,7 +18,7 @@ const emit = defineEmits<{
<template>
<nav
class="flex w-full scrollbar-none items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
class="scrollbar-none flex items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
aria-label="Category filter"
>
<button

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
type NavDropdownItem = {
export type NavDropdownItem = {
label: string
href: string
badge?: string

View File

@@ -37,7 +37,7 @@ const allCards: (ReturnType<typeof cardDef> & { product: Product })[] = [
cardDef('local', routes.download, 'bg-primary-warm-gray'),
cardDef('cloud', routes.cloud, 'bg-secondary-mauve'),
cardDef('api', routes.api, 'bg-primary-comfy-plum'),
cardDef('enterprise', routes.cloudEnterprise, 'bg-secondary-cool-gray')
cardDef('enterprise', routes.cloudEnterprise, 'bg-illustration-forest')
]
const cards = excludeProduct

View File

@@ -3,6 +3,7 @@ const logos = [
'Amazon Studios',
'Apple',
'Autodesk',
'EA',
'Harman',
'Hp',
'Lucid',

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import type { GalleryItem } from './GallerySection.vue'
import GalleryItemAttribution from './GalleryItemAttribution.vue'
const {

View File

@@ -10,13 +10,13 @@ import {
watch
} from 'vue'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import BrandButton from '../common/BrandButton.vue'
import GalleryItemAttribution from './GalleryItemAttribution.vue'
import type { GalleryItem } from './GallerySection.vue'
const {
items,
@@ -251,7 +251,7 @@ onUnmounted(() => {
<!-- Thumbnail strip -->
<div
class="mx-auto mt-6 h-16 max-w-full scrollbar-none overflow-x-auto px-6 lg:h-30"
class="scrollbar-none mx-auto mt-6 h-16 max-w-full overflow-x-auto px-6 lg:h-30"
>
<div class="flex items-end gap-3">
<button

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import type { GalleryItem } from './GallerySection.vue'
const {
item,

View File

@@ -2,8 +2,6 @@
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import { visibleGalleryItems as items } from '../../data/gallery'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import GalleryCard from './GalleryCard.vue'
import GalleryDetailModal from './GalleryDetailModal.vue'
@@ -18,6 +16,166 @@ function openDetail(index: number) {
modalOpen.value = true
}
export interface GalleryItem {
image?: string
video?: string
title: string
userAlias: string
teamAlias: string
tool: string
href?: string
}
const items: GalleryItem[] = [
{
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
},
{
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
title: 'Origins - Kyrie Irving',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://vimeo.com/1021360563'
},
{
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
title: 'Neon Nights',
userAlias: 'ShaneF Motion Design',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
},
{
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
title: 'Untitled',
userAlias: 'MidJourney man',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
},
{
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
title: 'Autopoiesis',
userAlias: 'Yogo',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/visualfrisson/?hl=en'
},
{
video:
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
title: 'Eat It - Dance',
userAlias: 'Johana Lyu',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.joannalyu.com/'
},
{
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
title: 'Fall',
userAlias: 'Nathan Shipley',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
},
{
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
title: 'Untitled',
userAlias: 'Nathan Shipley',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
},
{
video:
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
title: 'Origami world',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
title: 'Shot on InstaX',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
title: "It's gonna be a good good summer",
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685900'
},
{
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
title: 'DDU-DU DDU-DU',
userAlias: 'Purz',
teamAlias: 'Andidea',
tool: 'Animatediff',
href: 'https://vimeo.com/1019924290'
},
{
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
title: 'Cuco - A Love Letter To LA',
userAlias: 'Paul Trillo',
teamAlias: 'CoffeeVectors',
tool: 'ComfyUI',
href: 'https://vimeo.com/1062859798'
},
{
video:
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
title: 'Show you my garden',
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685479'
},
{
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
title: 'Goodbye Beijing',
userAlias: 'Rui',
teamAlias: 'makeitrad',
tool: 'Animatediff',
href: 'https://x.com/rui40000'
},
{
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
title: 'Animation Reel',
userAlias: 'Andidea',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
},
{
image: 'https://media.comfy.org/website/gallery/gallery.webp',
title: 'Amber Astronaut',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
},
{
image: 'https://media.comfy.org/website/gallery/desert.webp',
title: 'Desert Landing',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
}
]
/**
* Desktop layout pattern (repeating):
* Row A: full-width (1 item)

View File

@@ -1,189 +0,0 @@
export interface GalleryItem {
id: string
image?: string
video?: string
title: string
userAlias: string
teamAlias: string
tool: string
href?: string
/** Defaults to true. Set to false to hide this item from rendered lists. */
visible?: boolean
}
const galleryItems: GalleryItem[] = [
{
id: 'until-our-eye-interlink-harajuku',
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
},
{
id: 'origins-kyrie-irving',
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
title: 'Origins - Kyrie Irving',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://vimeo.com/1021360563'
},
{
id: 'neon-nights',
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
title: 'Neon Nights',
userAlias: 'ShaneF Motion Design',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
},
{
id: 'untitled-dusk-mountains',
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
title: 'Untitled',
userAlias: 'MidJourney man',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
},
{
id: 'autopoiesis',
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
title: 'Autopoiesis',
userAlias: 'Yogo',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/visualfrisson/?hl=en'
},
{
id: 'eat-it-dance',
video:
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
title: 'Eat It - Dance',
userAlias: 'Johana Lyu',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.joannalyu.com/'
},
{
id: 'fall',
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
title: 'Fall',
userAlias: 'Nathan Shipley',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
},
{
id: 'untitled-buildings',
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
title: 'Untitled',
userAlias: 'Nathan Shipley',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
},
{
id: 'origami-world',
video:
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
title: 'Origami world',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
id: 'shot-on-instax',
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
title: 'Shot on InstaX',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
id: 'good-good-summer',
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
title: "It's gonna be a good good summer",
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685900'
},
{
id: 'ddu-du-ddu-du',
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
title: 'DDU-DU DDU-DU',
userAlias: 'Purz',
teamAlias: 'Andidea',
tool: 'Animatediff',
href: 'https://vimeo.com/1019924290'
},
{
id: 'cuco-love-letter-to-la',
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
title: 'Cuco - A Love Letter To LA',
userAlias: 'Paul Trillo',
teamAlias: 'CoffeeVectors',
tool: 'ComfyUI',
href: 'https://vimeo.com/1062859798'
},
{
id: 'show-you-my-garden',
video:
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
title: 'Show you my garden',
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685479'
},
{
id: 'goodbye-beijing',
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
title: 'Goodbye Beijing',
userAlias: 'Rui',
teamAlias: 'makeitrad',
tool: 'Animatediff',
href: 'https://x.com/rui40000'
},
{
id: 'animation-reel',
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
title: 'Animation Reel',
userAlias: 'Andidea',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
},
{
id: 'amber-astronaut',
image: 'https://media.comfy.org/website/gallery/gallery.webp',
title: 'Amber Astronaut',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
},
{
id: 'desert-landing',
image: 'https://media.comfy.org/website/gallery/desert.webp',
title: 'Desert Landing',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
}
]
export const visibleGalleryItems: GalleryItem[] = galleryItems.filter(
(item) => item.visible !== false
)
/** @knipIgnoreUsedByStackedPR */
export function getGalleryItemById(id: string): GalleryItem | undefined {
return galleryItems.find((item) => item.id === id)
}

View File

@@ -1458,9 +1458,9 @@ const translations = {
// ContactSection
'gallery.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
'gallery.contact.heading': {
en: 'Built something cool with ComfyUI?<br> <a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Submit</a> your work to be featured on our website and socials and get seen by the global ComfyUI community.',
en: 'Built something cool with ComfyUI? <a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Submit</a> your work to be featured on our website and socials and get seen by the global ComfyUI community.',
'zh-CN':
'用 ComfyUI 创作了很酷的作品?<br><a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">提交</a>你的作品,展示在我们的网站和社交媒体上,让全球 ComfyUI 社区看到。'
'用 ComfyUI 创作了很酷的作品?<a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">提交</a>你的作品,展示在我们的网站和社交媒体上,让全球 ComfyUI 社区看到。'
},
// AboutHeroSection

View File

@@ -71,12 +71,10 @@ const websiteJsonLd = {
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#211927" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

View File

@@ -14,7 +14,7 @@ const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
interface DroppedRole {
export interface DroppedRole {
title: string
reason: string
}

View File

@@ -21,7 +21,7 @@ const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
interface DroppedNode {
export interface DroppedNode {
name: string
reason: string
}

View File

@@ -1,16 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
fetchGitHubStars,
formatStarCount,
resetGitHubStarsFetcherForTests
} from './github'
import { fetchGitHubStars, formatStarCount } from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
afterEach(() => {
resetGitHubStarsFetcherForTests()
vi.restoreAllMocks()
if (savedOverride === undefined)
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
@@ -32,67 +27,6 @@ describe('fetchGitHubStars', () => {
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
})
it('memoizes concurrent fetches for the same repo to one network call', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(JSON.stringify({ stargazers_count: 110000 }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
)
const [a, b, c] = await Promise.all([
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
])
expect(a).toBe(110000)
expect(b).toBe(110000)
expect(c).toBe(110000)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('keys the in-flight cache by owner/repo', async () => {
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
const href = typeof url === 'string' ? url : url.toString()
const count = href.includes('other-repo') ? 42 : 110000
return new Response(JSON.stringify({ stargazers_count: count }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
})
const [comfy, other] = await Promise.all([
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'other-repo', fetchImpl as typeof fetch)
])
expect(comfy).toBe(110000)
expect(other).toBe(42)
expect(fetchImpl).toHaveBeenCalledTimes(2)
})
it('returns null when GitHub responds non-2xx', async () => {
const fetchImpl = vi.fn(
async () => new Response('rate limited', { status: 403 })
)
await expect(
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
).resolves.toBeNull()
})
it('returns null when fetch throws', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('network down')
})
await expect(
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
).resolves.toBeNull()
})
})
describe('formatStarCount', () => {

View File

@@ -1,51 +1,22 @@
const inflight = new Map<string, Promise<number | null>>()
export function resetGitHubStarsFetcherForTests(): void {
inflight.clear()
}
export async function fetchGitHubStars(
owner: string,
repo: string,
fetchImpl: typeof fetch = fetch
repo: string
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
const key = `${owner}/${repo}`
const cached = inflight.get(key)
if (cached) return cached
const request = doFetch(owner, repo, fetchImpl)
inflight.set(key, request)
return request
}
async function doFetch(
owner: string,
repo: string,
fetchImpl: typeof fetch
): Promise<number | null> {
try {
const res = await fetchImpl(
`https://api.github.com/repos/${owner}/${repo}`,
{ headers: { Accept: 'application/vnd.github.v3+json' } }
)
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { Accept: 'application/vnd.github.v3+json' }
})
if (!res.ok) return null
const data: unknown = await res.json()
return readStargazerCount(data)
const data = await res.json()
return data.stargazers_count ?? null
} catch {
return null
}
}
function readStargazerCount(data: unknown): number | null {
if (data === null || typeof data !== 'object') return null
if (!('stargazers_count' in data)) return null
const count = data.stargazers_count
return typeof count === 'number' ? count : null
}
export function formatStarCount(count: number): string {
if (count >= 1_000_000) {
const m = count / 1_000_000

View File

@@ -1,197 +0,0 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 12,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": ["first-host", 11]
},
{
"id": 12,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [900, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": ["second-host", 22]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -1,176 +0,0 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["9999", "missing_widget"]]
},
"widgets_values": ["quarantined-host-value"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -66,34 +66,6 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
await this.drop(options)
}
async middleDrag(
from: Position,
to: Position,
options: Omit<DragOptions, 'button'> = {}
) {
await this.dragAndDrop(from, to, { ...options, button: 'middle' })
}
async middleDragFromCenter(
locator: Locator,
delta: { x: number; y: number },
options: Omit<DragOptions, 'button'> = {}
) {
await locator.waitFor({ state: 'visible' })
const box = await locator.boundingBox()
if (!box) throw new Error('middleDragFromCenter: bounding box not found')
const start = {
x: box.x + box.width / 2,
y: box.y + box.height / 2
}
await this.middleDrag(
start,
{ x: start.x + delta.x, y: start.y + delta.y },
options
)
}
/** @see {@link Mouse.move} */
async move(to: Position, options = ComfyMouse.defaultOptions) {
await this.mouse.move(to.x, to.y, options)

View File

@@ -139,7 +139,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
public readonly root: Locator
public readonly activeWorkflowLabel: Locator
public readonly searchInput: Locator
public readonly refreshButton: Locator
constructor(public override readonly page: Page) {
super(page, 'workflows')
@@ -148,9 +147,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
'.comfyui-workflows-open .p-tree-node-selected .node-label'
)
this.searchInput = this.root.getByRole('combobox').first()
this.refreshButton = this.root.getByTestId(
TestIds.sidebar.workflowsRefreshButton
)
}
async getOpenedWorkflowNames() {

View File

@@ -17,9 +17,8 @@ export class SubgraphEditor {
)
}
async ensureOpen(subgraphNode: Locator) {
async open(subgraphNode: Locator) {
await new VueNodeFixture(subgraphNode).select()
if (await this.root.isVisible()) return
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
await menu.clickMenuItemExact('Edit Subgraph Widgets')
await expect(this.root, 'Open Properties Panel').toBeVisible()
@@ -70,7 +69,7 @@ export class SubgraphEditor {
toState?: boolean
}
) {
await this.ensureOpen(subgraphNode)
await this.open(subgraphNode)
const item = this.resolveItem(options)
await this.togglePromotionOnItem(item, options.toState)

View File

@@ -6,9 +6,8 @@ import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSele
/**
* Helper for interacting with widgets rendered in app mode (linear view).
*
* Widgets are located by `nodeId:widgetName` suffix against the
* `data-widget-key` attribute, which carries the canonical
* `graphId:nodeId:widgetName` WidgetEntityId.
* Widgets are located by their key (format: "nodeId:widgetName") via the
* `data-widget-key` attribute on each widget item.
*/
export class AppModeWidgetHelper {
constructor(private readonly comfyPage: ComfyPage) {}
@@ -21,9 +20,9 @@ export class AppModeWidgetHelper {
return this.comfyPage.appMode.linearWidgets
}
/** Get a widget item container by its `nodeId:widgetName` suffix. */
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
getWidgetItem(key: string): Locator {
return this.container.locator(`[data-widget-key$=":${key}"]`)
return this.container.locator(`[data-widget-key="${key}"]`)
}
/** Get a FormDropdown widget by its key (e.g. "10:image"). */

View File

@@ -11,11 +11,6 @@ import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
type RunOptions = {
nodeErrors?: Record<string, NodeError>
onPromptRequest?: (requestBody: unknown) => void | Promise<void>
}
/**
* Build a `NodeError` describing a single failed input on a KSampler node.
* Shared between specs that surface validation rings via 400 responses.
@@ -75,9 +70,8 @@ export class ExecutionHelper {
* The app receives a valid PromptResponse so storeJob() fires
* and registers the job against the active workflow path.
*/
async run(options: RunOptions = {}): Promise<string> {
async run(): Promise<string> {
const jobId = `test-job-${++this.jobCounter}`
const { nodeErrors = {}, onPromptRequest } = options
let fulfilled!: () => void
const prompted = new Promise<void>((r) => {
@@ -87,13 +81,12 @@ export class ExecutionHelper {
await this.page.route(
PROMPT_ROUTE_PATTERN,
async (route) => {
await onPromptRequest?.(route.request().postDataJSON())
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: jobId,
node_errors: nodeErrors
node_errors: {}
})
})
fulfilled()

View File

@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
})
}
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
async setServerFlag(name: string, value: unknown): Promise<void> {
await this.setServerFlags({ [name]: value })
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -216,6 +216,16 @@ export class NodeOperationsHelper {
}
}
async convertAllNodesToGroupNode(groupNodeName: string): Promise<void> {
await this.comfyPage.canvas.press('Control+a')
const node = await this.getFirstNodeRef()
if (!node) {
throw new Error('No nodes found to convert')
}
await node.clickContextMenuOption('Convert to Group Node')
await this.fillPromptDialog(groupNodeName)
}
async fillPromptDialog(value: string): Promise<void> {
await this.promptDialogInput.fill(value)
await this.page.keyboard.press('Enter')

View File

@@ -14,8 +14,6 @@ import { TestIds } from '@e2e/fixtures/selectors'
import type { Position, Size } from '@e2e/fixtures/types'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
import { getAllHostPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
export class SubgraphHelper {
public readonly editor: SubgraphEditor
@@ -425,9 +423,39 @@ export class SubgraphHelper {
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
> {
return getAllHostPromotedWidgets(this.comfyPage)
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => {
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
)
return {
hostNodeId: String(node.id),
promotedWidgets
}
})
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
})
}
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */

View File

@@ -10,7 +10,6 @@ export const TestIds = {
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
workflowsRefreshButton: 'workflows-refresh-button',
modeToggle: 'mode-toggle'
},
tree: {
@@ -129,8 +128,7 @@ export const TestIds = {
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image',
slotConnectionDot: 'slot-connection-dot',
imageGrid: 'image-grid'
slotConnectionDot: 'slot-connection-dot'
},
selectionToolbox: {
root: 'selection-toolbox',

View File

@@ -511,7 +511,19 @@ export class NodeReference {
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
await this.comfyPage.contextMenu.clickMenuItem(optionText)
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
throw new Error(`Did not find single group node (found=${nodes.length})`)
}
return nodes[0]
}
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')

View File

@@ -1,77 +1,48 @@
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
function widgetSourceToEntry(
source: PromotedWidgetSource
): PromotedWidgetEntry {
return [source.sourceNodeId, source.sourceWidgetName]
}
function previewExposureToEntry(
exposure: PreviewExposure
): PromotedWidgetEntry {
return [exposure.sourceNodeId, exposure.sourcePreviewName]
}
export function isPromotedWidgetSource(
value: unknown
): value is PromotedWidgetSource {
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
return (
!!value &&
typeof value === 'object' &&
'sourceNodeId' in value &&
'sourceWidgetName' in value &&
typeof value.sourceNodeId === 'string' &&
typeof value.sourceWidgetName === 'string'
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
}
export function isNodeProperty(value: unknown): value is NodeProperty {
if (value === null || value === undefined) return false
const t = typeof value
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
if (!Array.isArray(value)) return []
return value.filter(isPromotedWidgetEntry)
}
export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
(id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
return []
return [
{
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
]
})
const serializedNode = node?.serialize()
return {
widgetSources,
previewExposures: serializedNode?.properties?.previewExposures
}
},
nodeId
)
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgets = node?.widgets ?? []
const exposures = isNodeProperty(previewExposures)
? parsePreviewExposures(previewExposures)
: []
return [
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
...exposures.map(previewExposureToEntry)
]
// Read the live promoted widget views from the host node instead of the
// serialized proxyWidgets snapshot, which can lag behind the current graph
// state during promotion and cleanup flows.
return widgets.flatMap((widget) => {
if (
widget &&
typeof widget === 'object' &&
'sourceNodeId' in widget &&
typeof widget.sourceNodeId === 'string' &&
'sourceWidgetName' in widget &&
typeof widget.sourceWidgetName === 'string'
) {
return [[widget.sourceNodeId, widget.sourceWidgetName]]
}
return []
})
}, nodeId)
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetNames(
@@ -107,29 +78,12 @@ export async function getPromotedWidgetCountByName(
nodeId: string,
widgetName: string
): Promise<number> {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
return promotedWidgets.filter(([, name]) => name === widgetName).length
}
export async function getAllHostPromotedWidgets(
comfyPage: ComfyPage
): Promise<{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]> {
const hostNodeIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => String(node.id))
})
const entries = await Promise.all(
hostNodeIds.map(async (hostNodeId) => ({
hostNodeId,
promotedWidgets: await getPromotedWidgets(comfyPage, hostNodeId)
}))
return comfyPage.page.evaluate(
([id, name]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgets = node?.widgets ?? []
return widgets.filter((widget) => widget.name === name).length
},
[nodeId, widgetName] as const
)
return entries.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
}

View File

@@ -1,15 +1,8 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
interface BoxOrigin {
readonly x: number
readonly y: number
}
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
public readonly header: Locator
@@ -22,9 +15,7 @@ export class VueNodeFixture {
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
public readonly imageGrid: Locator
public readonly content: Locator
public readonly resize: { bottomRight: Locator }
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -37,10 +28,7 @@ export class VueNodeFixture {
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
this.imageGrid = locator.getByTestId(TestIds.node.imageGrid)
this.content = locator.locator('.lg-node-content')
const bottomRight = locator.getByRole('button', { name: 'bottom-right' })
this.resize = { bottomRight }
}
async getTitle(): Promise<string> {
@@ -89,100 +77,4 @@ export class VueNodeFixture {
: slotLocators.filter({ has: nameOrLocator })
return filteredLocator.getByTestId('slot-dot').locator('..')
}
/**
* Click the node header to select it, then return its bounding box.
* Throws if the node is not laid out because geometry-sensitive tests
* cannot proceed without coordinates.
*/
async selectAndGetBox(): Promise<{
x: number
y: number
width: number
height: number
}> {
await this.header.click()
const box = await this.boundingBox()
if (!box) {
throw new Error('Node bounding box not found after select')
}
return box
}
/**
* Assert this node's top-left origin stays within `precision` decimal
* places of `expected`. Wraps the polled bounding-box pattern that drift
* tests repeat for both axes.
*/
async expectAnchoredAt(
expected: BoxOrigin,
{ precision = 1 }: { precision?: number } = {}
): Promise<void> {
await expect.poll(this.pollLeftEdge).toBeCloseTo(expected.x, precision)
await expect.poll(this.pollTopEdge).toBeCloseTo(expected.y, precision)
}
/** Poll the node's left/x edge for use with `expect.poll`. */
pollLeftEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.x ?? null
/** Poll the node's top/y edge for use with `expect.poll`. */
pollTopEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.y ?? null
/** Poll the node's right edge (x + width) for use with `expect.poll`. */
pollRightEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.x + b.width : null
}
/** Poll the node's bottom edge (y + height) for use with `expect.poll`. */
pollBottomEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.y + b.height : null
}
/** Poll the node's width for use with `expect.poll`. */
pollWidth = async (): Promise<number | null> =>
(await this.boundingBox())?.width ?? null
/** Poll the node's height for use with `expect.poll`. */
pollHeight = async (): Promise<number | null> =>
(await this.boundingBox())?.height ?? null
/** Locator for the resize handle at the given corner, scoped to this node. */
getResizeHandle(corner: CompassCorners): Locator {
return this.root.locator(`[data-corner="${corner}"]`)
}
/**
* Drag the resize handle at `corner` by (deltaX, deltaY) viewport pixels.
* Uses `hover()` to land the pointer on the handle with Playwright's
* actionability checks before starting the mouse sequence, which protects
* against occluding overlays and subpixel hit-test misses.
*/
async resizeFromCorner(
corner: CompassCorners,
deltaX: number,
deltaY: number
): Promise<void> {
const handle = this.getResizeHandle(corner)
await handle.hover()
const box = await handle.boundingBox()
if (!box) {
throw new Error(
`Resize handle for corner "${corner}" has no bounding box`
)
}
const page = this.locator.page()
const startX = box.x + box.width / 2
const startY = box.y + box.height / 2
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX + deltaX, startY + deltaY, {
steps: 5
})
await page.mouse.up()
}
}

View File

@@ -52,7 +52,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-off.png',
{ clip: hudClip, maxDiffPixels: 100 }
{ clip: hudClip, maxDiffPixels: 50 }
)
})
@@ -61,7 +61,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-on.png',
{ clip: hudClip, maxDiffPixels: 100 }
{ clip: hudClip, maxDiffPixels: 50 }
)
})
}

View File

@@ -157,13 +157,6 @@ test.describe('Signin dialog', () => {
})
test('Sign-in dialog resolves true on login', async ({ comfyPage }) => {
await comfyPage.page.route('**/customers', (route) =>
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-user-e2e', email: 'test@example.com' })
})
)
const dialog = new SignInDialog(comfyPage.page)
const { result: dialogResult } = await dialog.openWithResult()

View File

@@ -1,60 +1,7 @@
import { mergeTests } from '@playwright/test'
import { expect } from '@playwright/test'
import type { NodeError } from '@/schemas/apiSchema'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const VALIDATION_ERROR_NODE_ID = '1'
const VALIDATION_ERROR_MESSAGE = 'Required input is missing: source'
const PARTIAL_EXECUTION_ROOT_NODE_IDS = ['1', '4']
type PromptRequestNode = {
class_type?: string
}
type PromptRequestBody = {
prompt?: Record<string, PromptRequestNode>
}
function buildPreviewAnyValidationError(): NodeError {
return {
class_type: 'PreviewAny',
dependent_outputs: [VALIDATION_ERROR_NODE_ID],
errors: [
{
type: 'required_input_missing',
message: VALIDATION_ERROR_MESSAGE,
details: '',
extra_info: { input_name: 'source' }
}
]
}
}
function expectPartialExecutionRootNodes(requestBody: unknown): void {
const prompt = (requestBody as PromptRequestBody).prompt ?? {}
for (const nodeId of PARTIAL_EXECUTION_ROOT_NODE_IDS) {
expect(prompt[nodeId]).toMatchObject({ class_type: 'PreviewAny' })
}
}
async function getValidationErrorMessage(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate(
(nodeId) =>
window.app!.extensionManager.lastNodeErrors?.[nodeId]?.errors[0]
?.message ?? null,
VALIDATION_ERROR_NODE_ID
)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -127,48 +74,3 @@ test.describe(
})
}
)
test.describe('Execution validation errors', { tag: '@workflow' }, () => {
test('preserves validation errors when another active root starts execution', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
const nodeErrors = {
[VALIDATION_ERROR_NODE_ID]: buildPreviewAnyValidationError()
}
let promptRequestBody: unknown
const jobId = await exec.run({
nodeErrors,
onPromptRequest: (requestBody) => {
promptRequestBody = requestBody
}
})
expectPartialExecutionRootNodes(promptRequestBody)
await expect
.poll(() => getValidationErrorMessage(comfyPage))
.toBe(VALIDATION_ERROR_MESSAGE)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await comfyPage.nextFrame()
exec.executionStart(jobId)
await expect
.poll(() => getValidationErrorMessage(comfyPage))
.toBe(VALIDATION_ERROR_MESSAGE)
await expect(errorOverlay).toBeVisible()
})
})

View File

@@ -7,14 +7,9 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
import { TestIds } from '@e2e/fixtures/selectors'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
const GROUP_NODE_NAME = 'group_node'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
@@ -23,19 +18,22 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
const groupNodeBookmarkName = `workflow>${groupNodeName}`
let libraryTab: NodeLibrarySidebarTab
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
libraryTab = comfyPage.menu.nodeLibraryTab
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await libraryTab.open()
})
test('Is added to node library sidebar', async ({
comfyPage: _comfyPage
}) => {
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
})
test('Can be added to canvas using node library sidebar', async ({
@@ -43,8 +41,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
}) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab.getNode(GROUP_NODE_NAME).click()
// Add group node from node library sidebar
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getNode(groupNodeName).click()
// Verify the node is added to the canvas
await expect
@@ -53,9 +52,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(GROUP_NODE_NAME)
.getNode(groupNodeName)
.locator('.bookmark-button')
.click()
@@ -64,12 +63,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
.poll(() =>
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
)
.toEqual([GROUP_NODE_BOOKMARK])
.toEqual([groupNodeBookmarkName])
// Verify the bookmark node with the same name is added to the tree
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
// Unbookmark the node
await libraryTab
.getNode(GROUP_NODE_NAME)
.getNode(groupNodeName)
.locator('.bookmark-button')
.first()
.click()
@@ -83,9 +83,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(GROUP_NODE_NAME)
.getNode(groupNodeName)
.locator('.bookmark-button')
.click()
await comfyPage.page
@@ -96,57 +96,72 @@ test.describe('Group Node', { tag: '@node' }, () => {
comfyPage.page.locator('.node-lib-node-preview')
).toBeVisible()
await libraryTab
.getNode(GROUP_NODE_NAME)
.getNode(groupNodeName)
.locator('.bookmark-button')
.first()
.click()
})
})
test('Can be added to canvas using search', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
test(
'Can be added to canvas using search',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(groupNodeName)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${groupNodeName}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
.toHaveLength(2)
})
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)
}
)
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
const pos = await groupNode.getPosition()
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
const makeGroup = async (name: string, type1: string, type2: string) => {
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
await node1.click('title')
await node2.click('title', {
modifiers: ['Shift']
})
return await node2.convertToGroupNode(name)
}
const manage = await groupNode.manageGroupNode()
const group1 = await makeGroup(
'g1',
'CLIPTextEncode',
'CheckpointLoaderSimple'
)
const group2 = await makeGroup('g2', 'EmptyLatentImage', 'KSampler')
const manage1 = await group1.manageGroupNode()
await comfyPage.nextFrame()
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
await manage.close()
await expect(manage.root).toBeHidden()
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
await manage1.close()
await expect(manage1.root).toBeHidden()
const manage2 = await group2.manageGroupNode()
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
})
test('Preserves hidden input configuration when containing duplicate node types', async ({
@@ -186,6 +201,42 @@ test.describe('Group Node', { tag: '@node' }, () => {
.toBe(2)
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
}
const latent = await expectSingleNode('EmptyLatentImage')
const sampler = await expectSingleNode('KSampler')
// Remove existing link
const samplerInput = await sampler.getInput(0)
await samplerInput.removeLinks()
// Group latent + sampler
await latent.click('title', {
modifiers: ['Shift']
})
await sampler.click('title', {
modifiers: ['Shift']
})
const groupNode = await sampler.convertToGroupNode()
// Connect node to group
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
const input = await ckpt.connectOutput(0, groupNode, 0)
await expect.poll(() => input.getLinkCount()).toBe(1)
// Modify the group node via manage dialog
const manage = await groupNode.manageGroupNode()
await manage.selectNode('KSampler')
await manage.changeTab('Inputs')
await manage.setLabel('model', 'test')
await manage.save()
await manage.close()
// Ensure the link is still present
await expect.poll(() => input.getLinkCount()).toBe(1)
})
test('Loads from a workflow using the legacy path separator ("/")', async ({
comfyPage
}) => {
@@ -198,6 +249,11 @@ test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Copy and paste', () => {
let groupNode: NodeReference | null
const WORKFLOW_NAME = 'groupnodes/group_node_v1.3.3'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_PREFIX = 'workflow>'
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
return await comfyPage.page.evaluate((nodeType: string) => {
@@ -226,10 +282,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
await groupNode.copy()
})
@@ -243,7 +299,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Copies and pastes group node after clearing workflow', async ({
comfyPage
}) => {
// Set setting
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
// Clear workflow
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await comfyPage.clipboard.paste()
@@ -283,6 +342,24 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
})
})
test.describe('Keybindings', () => {
test('Convert to group node, no selection', async ({ comfyPage }) => {
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.page.keyboard.press('Alt+g')
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Alt+g')
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
})
})
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({

View File

@@ -76,34 +76,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await maskEditor.drawStrokeAndExpectPixels(dialog)
})
test(
'Middle-click drag should pan the mask editor canvas',
{ tag: ['@canvas'] },
async ({ comfyPage, comfyMouse, maskEditor }) => {
const dialog = await maskEditor.openDialog()
const pointerZone = dialog.getByTestId('pointer-zone')
const getCanvasPosition = () =>
comfyPage.page.evaluate(() => {
const container = document.querySelector('#maskEditorCanvasContainer')
if (!(container instanceof HTMLElement)) return null
return {
left: container.style.left,
top: container.style.top
}
})
const canvasPositionBefore = await getCanvasPosition()
await comfyMouse.middleDragFromCenter(
pointerZone,
{ x: 140, y: 90 },
{ steps: 10 }
)
await expect.poll(getCanvasPosition).not.toEqual(canvasPositionBefore)
}
)
test('undo reverts a brush stroke', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -9,7 +9,7 @@ test.describe(
() => {
test.beforeEach(async ({ comfyPage }) => {
// Keep the viewport well below the menu content height so overflow is guaranteed.
await comfyPage.page.setViewportSize({ width: 1280, height: 300 })
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')

View File

@@ -46,8 +46,15 @@ test.describe(
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 600 })
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()

View File

@@ -233,21 +233,21 @@ test.describe('Node search box', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('CLIP', 'Output Type')
await comfyPage.searchBox.addFilter('utilities', 'Category')
await comfyPage.searchBox.addFilter('utils', 'Category')
})
test('Can remove first filter', async ({ comfyPage }) => {
await comfyPage.searchBox.removeFilter(0)
await expectFilterChips(comfyPage, ['CLIP', 'utilities'])
await expectFilterChips(comfyPage, ['CLIP', 'utils'])
await comfyPage.searchBox.removeFilter(0)
await expectFilterChips(comfyPage, ['utilities'])
await expectFilterChips(comfyPage, ['utils'])
await comfyPage.searchBox.removeFilter(0)
await expectFilterChips(comfyPage, [])
})
test('Can remove middle filter', async ({ comfyPage }) => {
await comfyPage.searchBox.removeFilter(1)
await expectFilterChips(comfyPage, ['MODEL', 'utilities'])
await expectFilterChips(comfyPage, ['MODEL', 'utils'])
})
test('Can remove last filter', async ({ comfyPage }) => {

View File

@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
})
test.describe('Empty graph defaults', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlag(
'node_library_essentials_enabled',
true
)
})
test('Defaults to Essentials when graph is empty', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await searchBoxV2.open()
const essentialsBtn = searchBoxV2.rootCategoryButton(
RootCategory.Essentials
)
await expect(essentialsBtn).toBeVisible()
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
})
test('Defaults to Most Relevant when graph has nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
await searchBoxV2.open()
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
await expect(
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
).toHaveAttribute('aria-pressed', 'false')
})
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -3,40 +3,36 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test(
'Price badge displays on subgraphs',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
}
)
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -35,6 +35,23 @@ test.describe(
'add-group-group-added.png'
)
})
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.canvasOps.rightClick()
await comfyPage.contextMenu.clickMenuItem(
'Convert to Group Node (Deprecated)'
)
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-group-node.png'
)
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -56,34 +56,6 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
await expect(nodeRef).not.toBeCollapsed()
})
test('More Options menu does not surface duplicate LiteGraph Resize / Collapse / Expand entries', async ({
comfyPage
}) => {
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
await comfyPage.nodeOps.selectNodeWithPan(nodeRef)
const menu = await openMoreOptions(comfyPage)
await expect(
menu.getByText('Minimize Node', { exact: true })
).toBeVisible()
await expect(
menu.getByRole('menuitem', { name: 'Resize', exact: true })
).toHaveCount(0)
await expect(
menu.getByRole('menuitem', { name: 'Collapse', exact: true })
).toHaveCount(0)
await menu.getByText('Minimize Node', { exact: true }).click()
await openMoreOptions(comfyPage)
await expect(
menu.getByRole('menuitem', { name: 'Expand', exact: true })
).toHaveCount(0)
})
test('copy via More Options menu', async ({ comfyPage }) => {
const nodeRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')

View File

@@ -1,10 +1,8 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { openErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import type { UserDataFullInfo } from '@/schemas/apiSchema'
test.describe('Workflows sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -47,56 +45,6 @@ test.describe('Workflows sidebar', () => {
.toEqual(expect.arrayContaining(['workflow1', 'workflow2']))
})
test(
'Shows loading state while refreshing workflows',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
const workflowsSyncRoute = /\/api\/userdata\?[^#]*\bdir=workflows\b/
const emptyWorkflowList: UserDataFullInfo[] = []
let releaseSync!: () => void
const syncBlocked = new Promise<void>((resolve) => {
releaseSync = resolve
})
const syncFulfillments: Promise<void>[] = []
const holdSyncResponse = async (route: Route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
const syncFulfilled = syncBlocked.then(() =>
route.fulfill({ json: emptyWorkflowList })
)
syncFulfillments.push(syncFulfilled)
await syncFulfilled
}
await comfyPage.page.route(workflowsSyncRoute, holdSyncResponse)
try {
const syncRequest = comfyPage.page.waitForRequest((request) =>
workflowsSyncRoute.test(request.url())
)
await tab.refreshButton.click()
await syncRequest
await expect(tab.refreshButton).toBeDisabled()
await expect(tab.refreshButton).toHaveAttribute('aria-busy', 'true')
} finally {
releaseSync()
await Promise.all(syncFulfillments)
await comfyPage.page.unroute(workflowsSyncRoute, holdSyncResponse)
}
await expect(tab.refreshButton).toBeEnabled()
await expect(tab.refreshButton).toHaveAttribute('aria-busy', 'false')
}
)
test('Can duplicate workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1')

View File

@@ -40,19 +40,49 @@ test.describe(
)
const [nodeId1, nodeId2] = nodeIds
const promotedTextarea = (nodeId: string) =>
comfyPage.vueNodes
.getNodeLocator(nodeId)
.getByRole('textbox', { name: 'text' })
// Enter first subgraph, set text widget value
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea1.fill('subgraph1_value')
await expect(textarea1).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
await promotedTextarea(nodeId1).fill('subgraph1_value')
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
// Enter second subgraph, set text widget value
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea2.fill('subgraph2_value')
await expect(textarea2).toHaveValue('subgraph2_value')
await comfyPage.subgraph.exitViaBreadcrumb()
await promotedTextarea(nodeId2).fill('subgraph2_value')
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
// Re-enter first subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea1Again).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
// Re-enter second subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea2Again).toHaveValue('subgraph2_value')
})
}
)

View File

@@ -1,87 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
async function waitForRootCanvasReady(page: Page) {
await expect
.poll(async () => {
const state = await page.evaluate(() => ({
rootId: window.app?.rootGraph?.id ?? '',
canvasGraphId: window.app?.canvas?.graph?.id ?? ''
}))
return state.rootId !== '' && state.canvasGraphId === state.rootId
})
.toBe(true)
}
async function expectCanvasOnRootGraph(page: Page) {
await expect
.poll(async () =>
page.evaluate(() => ({
rootId: window.app!.rootGraph.id,
canvasGraphId: window.app!.canvas.graph?.id,
hash: window.location.hash
}))
)
.toEqual({
rootId: expect.any(String),
canvasGraphId: expect.stringMatching(/.+/),
hash: expect.stringMatching(/^#.+/)
})
const state = await page.evaluate(() => ({
rootId: window.app!.rootGraph.id,
canvasGraphId: window.app!.canvas.graph?.id,
hash: window.location.hash
}))
expect(state.canvasGraphId).toBe(state.rootId)
expect(state.hash).toBe(`#${state.rootId}`)
}
test.describe(
'Subgraph hash validation (FE-559)',
{ tag: ['@subgraph'] },
() => {
test('redirects URL and canvas to root for a non-existent subgraph hash', async ({
comfyPage
}) => {
await waitForRootCanvasReady(comfyPage.page)
const rootId = await comfyPage.page.evaluate(
() => window.app!.rootGraph.id
)
const phantomId = '11111111-1111-4111-8111-111111111111'
expect(phantomId).not.toBe(rootId)
await comfyPage.page.evaluate((hash) => {
window.location.hash = hash
}, `#${phantomId}`)
await expect
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
timeout: 5000
})
.toBe(`#${rootId}`)
await expectCanvasOnRootGraph(comfyPage.page)
})
test('redirects URL and canvas to root when hash is malformed', async ({
comfyPage
}) => {
await waitForRootCanvasReady(comfyPage.page)
const rootId = await comfyPage.page.evaluate(
() => window.app!.rootGraph.id
)
await comfyPage.page.evaluate(() => {
window.location.hash = '#not-a-valid-uuid'
})
await expect
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
timeout: 5000
})
.toBe(`#${rootId}`)
await expectCanvasOnRootGraph(comfyPage.page)
})
}
)

View File

@@ -1,43 +1,38 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
const domPreviewSelector = '.image-preview'
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
test.describe(
'Cleanup Behavior After Promoted Source Removal',
{ tag: ['@vue-nodes'] },
() => {
test('Deleting the promoted source removes the exterior promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test('Deleting the promoted source removes the exterior DOM widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextarea = subgraphNode.getByRole('textbox', {
name: 'text'
})
await expect(promotedTextarea).toBeVisible()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await comfyPage.vueNodes.enterSubgraph('11')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveCount(0)
})
}
)
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({

View File

@@ -34,43 +34,49 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@widget', '@vue-nodes'] },
{ tag: ['@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const OUTER_NODE_ID = '4'
const INNER_SUBGRAPH_NODE_ID = '3'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
await comfyExpect(async () => {
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const outerWidgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(outerWidgets).toHaveCount(1)
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
const exposedTextWidget = outerNode.getByRole('textbox', {
name: 'text'
})
await comfyExpect(exposedTextWidget).toHaveValue('22222222222')
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
await comfyPage.vueNodes.enterSubgraph(OUTER_NODE_ID)
const textWidgets = widgetValues.filter((w) =>
w.name.startsWith('text')
)
comfyExpect(textWidgets).toHaveLength(2)
const innerNode = comfyPage.vueNodes.getNodeLocator(
INNER_SUBGRAPH_NODE_ID
)
await comfyExpect(innerNode).toBeVisible()
const innerTextboxes = innerNode.getByRole('textbox')
await comfyExpect(innerTextboxes).toHaveCount(2)
const innerValues = await innerTextboxes.evaluateAll<
string[],
HTMLInputElement
>((boxes) => boxes.map((b) => b.value))
comfyExpect(innerValues).toContain('11111111111')
comfyExpect(innerValues).toContain('22222222222')
const values = textWidgets.map((w) => w.value)
comfyExpect(values).toContain('11111111111')
comfyExpect(values).toContain('22222222222')
}).toPass({ timeout: 5_000 })
})
}
)
@@ -90,6 +96,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeLocator).toBeVisible()
@@ -122,6 +129,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeAfter).toBeVisible()
@@ -168,6 +176,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
await comfyExpect(outerNode).toBeVisible()
@@ -201,6 +210,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
@@ -221,6 +231,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
@@ -239,6 +250,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
await expect
.poll(async () => {
@@ -256,6 +268,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
@@ -266,6 +279,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const initialCount = await widgets.count()
await comfyPage.subgraph.serializeAndReload()
await comfyPage.vueNodes.waitForNodes()
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)

View File

@@ -61,12 +61,15 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select the positive CLIPTextEncode node (id 6)
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
await clipNode.click('title')
const subgraphNode = await clipNode.convertToSubgraph()
await comfyPage.nextFrame()
const nodeId = String(subgraphNode.id)
// CLIPTextEncode is in the recommendedNodes list, so its text widget
// should be promoted
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
})
@@ -75,6 +78,7 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.centerOnNode()
@@ -82,6 +86,7 @@ test.describe(
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
await expectPromotedWidgetNamesToContain(
comfyPage,
String(subgraphNode.id),
@@ -90,73 +95,88 @@ test.describe(
})
})
test.describe(
'Promoted Widget Visibility in Vue Mode',
{ tag: ['@vue-nodes'] },
() => {
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
test('Promoted text widget is visible on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
})
const enterButton = subgraphVueNode.getByTestId(
'subgraph-enter-button'
)
await expect(enterButton).toBeVisible()
test.describe('Promoted Widget Visibility in Vue Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
await expect(nodeBody).toBeVisible()
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
}
)
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
test.describe('Promoted Widget Reactivity', { tag: ['@vue-nodes'] }, () => {
test.fail(
'Promoted and interior widgets stay in sync across navigation',
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
await expect(nodeBody).toBeVisible()
const testContent = 'promoted-value-sync-test'
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
const promotedTextarea = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await promotedTextarea.fill(testContent)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
})
await comfyPage.vueNodes.enterSubgraph('11')
test.describe('Promoted Widget Reactivity', () => {
test('Promoted and interior widgets stay in sync across navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const interiorTextarea = comfyPage.page
.locator('[data-node-id]')
.getByRole('textbox', { name: 'text' })
.first()
await expect(interiorTextarea).toHaveValue(testContent)
const testContent = 'promoted-value-sync-test'
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveValue(updatedInteriorContent)
}
)
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
})
})
test.describe('Manual Promote/Demote via Context Menu', () => {
@@ -175,6 +195,7 @@ test.describe(
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
@@ -183,8 +204,10 @@ test.describe(
await promoteEntry.click()
await expect(promoteEntry).toBeHidden()
// Navigate back to parent
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
})
@@ -193,6 +216,7 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// First promote a canvas-rendered widget (KSampler "steps")
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -208,6 +232,7 @@ test.describe(
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
// Wait for the context menu to close, confirming the action completed.
await expect(promoteEntry).toBeHidden()
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -255,9 +280,11 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(clipNode).toBeVisible()
@@ -290,6 +317,8 @@ test.describe(
'subgraphs/subgraph-with-preview-node'
)
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
await expectPromotedWidgetNamesToContain(
comfyPage,
'5',
@@ -302,6 +331,7 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.centerOnNode()
@@ -309,6 +339,7 @@ test.describe(
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is a recommended node, so filename_prefix should be promoted
const nodeId = String(subgraphNode.id)
await expectPromotedWidgetNamesToContain(
comfyPage,
@@ -325,6 +356,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
@@ -361,48 +393,56 @@ test.describe(
})
})
test.describe(
'Nested Promoted Widget Disabled State',
{ tag: ['@vue-nodes'] },
() => {
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.toEqual(expect.arrayContaining(['string_a', 'value']))
await expect
.poll(async () => {
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
return disabledState.find((w) => w.name === 'string_a')?.disabled
})
.toBe(true)
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
let editedTextarea = false
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.toEqual(expect.arrayContaining(['string_a', 'value']))
const subgraphNode = comfyPage.vueNodes.getNodeLocator('5')
const linkedTextarea = subgraphNode.getByRole('textbox', {
name: 'string_a',
exact: true
})
await expect(linkedTextarea).toBeVisible()
await expect(linkedTextarea).toBeDisabled()
const allTextareas = subgraphNode.getByRole('textbox')
await expect(allTextareas.first()).toBeVisible()
let editedTextarea = false
const count = await allTextareas.count()
for (let i = 0; i < count; i++) {
const textarea = allTextareas.nth(i)
if (await textarea.isEditable()) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
}
if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
}
expect(editedTextarea).toBe(true)
})
}
)
}
expect(editedTextarea).toBe(true)
})
})
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
@@ -412,13 +452,16 @@ test.describe(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Verify promotions exist
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '11'))
.toEqual(expect.arrayContaining([expect.anything()]))
// Delete the subgraph node
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
// Node no longer exists, so promoted widgets should be gone
await expect.poll(() => subgraphNode.exists()).toBe(false)
})
@@ -477,13 +520,17 @@ test.describe(
.toBeGreaterThan(0)
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Remove the text input slot
await comfyPage.subgraph.removeSlot('input', 'text')
// Navigate back via breadcrumb
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.toBeLessThan(initialWidgetCount)
@@ -541,6 +588,7 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.vueNodes.waitForNodes()
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
@@ -560,198 +608,216 @@ test.describe(
}
)
test(
'Promote/Demote by Context Menu',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(steps).toBeVisible()
})
await expect(steps).toBeVisible()
})
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
}
)
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
})
test(
'Properties panel operations',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: false
})
await expect(steps, 'Un-promote widget').toBeHidden()
})
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'linked widgets are first by default'
).toHaveText('steps')
await editor.open(subgraphNode)
await editor.dragItem(0, 1)
await expect(
editor.promotionItems.first(),
'Swap widget order'
).toContainText('cfg')
// FIXME: solve actual bug and remove the not
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'Linked widget is first on node'
).not.toHaveText('cfg')
})
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.subgraph.unpromoteWidget(ksampler.root, 'steps')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(steps, 'Un-promote widget').toBeHidden()
}
)
})
test(
'Link already promoted widget',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await expect(steps).toHaveCount(1)
})
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
})
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await expect(subgraph.content).toHaveCount(1)
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph
.getInputSlot()
.getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(steps).toHaveCount(1)
}
)
await expect(subgraph.content).toHaveCount(2)
})
// FUTURE: Add test for re-ordering previews?
test(
'Can promote multiple previews',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
})
await expect(subgraph.content).toHaveCount(1)
})
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
})
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(1)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(2)
})
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
})
await expect(subgraph.content).toHaveCount(1)
})
}
)
test(
'Linked widgets can not be demoted',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph
.getInputSlot()
.getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await editor.ensureOpen(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
}
)
await editor.open(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
})

View File

@@ -5,6 +5,8 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
const TEST_WIDGET_CONTENT = 'Test content that should persist'
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
@@ -29,125 +31,133 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
.toBe(true)
}
test.describe(
'Subgraph Promotion DOM',
{ tag: ['@subgraph', '@vue-nodes'] },
() => {
test('Promoted seed widget renders in node body, not header', async ({
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('seed')
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNodeId = String(subgraphNode.id)
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
await parentTextarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('seed')
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
await openSubgraphById(comfyPage, '11')
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.keyboard.press('Escape')
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test.describe(
'Promoted Text Widget Lifecycle',
{ tag: ['@vue-nodes'] },
() => {
test('Promoted text widget preserves content through subgraph enter/exit', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextarea = subgraphNode.getByRole('textbox', {
name: 'text'
})
await expect(promotedTextarea).toBeVisible()
await promotedTextarea.fill(TEST_WIDGET_CONTENT)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
await openSubgraphById(comfyPage, '11')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
await comfyPage.keyboard.press('Escape')
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
})
const backToPromoted = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(backToPromoted).toBeVisible()
await expect(backToPromoted).toHaveValue(TEST_WIDGET_CONTENT)
})
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test('Promoted text widget is removed when subgraph node is deleted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(
subgraphNode.getByRole('textbox', { name: 'text' })
).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNodeRef.delete()
await openSubgraphById(comfyPage, '11')
await expect(subgraphNode).toHaveCount(0)
})
await comfyPage.subgraph.removeSlot('input', 'text')
test('Promoted text widget disappears when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.subgraph.exitViaBreadcrumb()
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(
subgraphNode.getByRole('textbox', { name: 'text' })
).toBeVisible()
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(0)
})
await openSubgraphById(comfyPage, '11')
await comfyPage.subgraph.removeSlot('input', 'text')
await comfyPage.subgraph.exitViaBreadcrumb()
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveCount(0)
})
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
await expect(visibleWidgets).toHaveCount(2)
const parentCount = await visibleWidgets.count()
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextareas = subgraphNode.getByRole('textbox')
await expect(promotedTextareas).toHaveCount(2)
await openSubgraphById(comfyPage, '11')
await openSubgraphById(comfyPage, '11')
await expect(visibleWidgets).toHaveCount(parentCount)
const interiorTextareas = comfyPage.page
.locator('[data-node-id]')
.getByRole('textbox')
await expect(interiorTextareas).toHaveCount(2)
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.vueNodes.getNodeLocator('11').getByRole('textbox')
).toHaveCount(2)
})
}
)
}
)
await expect(visibleWidgets).toHaveCount(parentCount)
})
})
})

View File

@@ -14,87 +14,6 @@ import {
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW =
'subgraphs/subgraph-primitive-fanout-multi-host'
const UNRESOLVABLE_PROXY_WORKFLOW =
'subgraphs/subgraph-unresolvable-proxy-widget'
interface HostWidgetSnapshot {
name: string
sourceNodeId: string | null
sourceWidgetName: string | null
value: unknown
}
interface PrimitiveFanoutSnapshot {
hostWidgetNames: string[]
hostWidgetValues: HostWidgetSnapshot[]
interiorWidgetValues: unknown[]
primitiveOutputLinks: unknown
primitiveOriginLinkCount: number
serializedProperties: Record<string, unknown>
}
async function getPrimitiveFanoutSnapshot(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<PrimitiveFanoutSnapshot> {
return comfyPage.page.evaluate((id) => {
const graph = window.app!.canvas.graph!
const hostNode = graph.getNodeById(Number(id))
if (!hostNode?.isSubgraphNode?.()) {
throw new Error(`Host node ${id} is not a SubgraphNode`)
}
const [primitiveNode] = hostNode.subgraph.findNodesByType(
'PrimitiveNode',
[]
)
const primitiveOriginLinkCount = [
...hostNode.subgraph._links.values()
].filter((link) => link.origin_id === primitiveNode?.id).length
const serialized = window.app!.graph!.serialize()
const serializedNode = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
return {
hostWidgetNames: (hostNode.widgets ?? []).map((widget) => widget.name),
hostWidgetValues: (hostNode.widgets ?? []).map((widget) => ({
name: widget.name,
sourceNodeId:
'sourceNodeId' in widget && typeof widget.sourceNodeId === 'string'
? widget.sourceNodeId
: null,
sourceWidgetName:
'sourceWidgetName' in widget &&
typeof widget.sourceWidgetName === 'string'
? widget.sourceWidgetName
: null,
value: widget.value
})),
interiorWidgetValues: hostNode.subgraph._nodes.flatMap((node) =>
(node.widgets ?? []).map((widget) => widget.value)
),
primitiveOutputLinks: primitiveNode?.outputs?.[0]?.links ?? null,
primitiveOriginLinkCount,
serializedProperties: serializedNode?.properties ?? {}
}
}, hostNodeId)
}
async function getSerializedSubgraphNodeProperties(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<Record<string, unknown>> {
return comfyPage.page.evaluate((id) => {
const serialized = window.app!.graph!.serialize()
const node = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
return node?.properties ?? {}
}, hostNodeId)
}
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
@@ -122,173 +41,23 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test(
'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'
)
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
.toBeGreaterThan(1)
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
await expect(beforeReload).toBeVisible()
const host = comfyPage.vueNodes.getNodeLocator('2')
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
await comfyPage.subgraph.serializeAndReload()
const beforeReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
expect(beforeReload.hostWidgetNames).toContain('value')
expect(beforeReload.primitiveOriginLinkCount).toBe(0)
expect(beforeReload.primitiveOutputLinks ?? []).toEqual([])
expect(beforeReload.serializedProperties).not.toHaveProperty(
'proxyWidgets'
)
expect(beforeReload.serializedProperties).not.toHaveProperty(
'proxyWidgetErrorQuarantine'
)
await comfyPage.subgraph.serializeAndReload()
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
const afterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
expect(afterReload.interiorWidgetValues).toEqual(
beforeReload.interiorWidgetValues
)
expect(
afterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe(
beforeReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
)
expect(afterReload.primitiveOriginLinkCount).toBe(0)
expect(afterReload.serializedProperties).not.toHaveProperty(
'proxyWidgets'
)
}
)
test(
'Multiple SubgraphNode hosts keep independent migrated widget values',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW
)
const expectHostHasIndependentValues = async (
hostId: string,
stringValue: string,
intValue: string
) => {
const host = comfyPage.vueNodes.getNodeLocator(hostId)
const widgets = host.getByTestId(TestIds.widgets.widget)
await expect(widgets).toHaveCount(2)
await expect(widgets.nth(0).locator('input').first()).toHaveValue(
stringValue
)
await expect(widgets.nth(1).locator('input').first()).toHaveValue(
intValue
)
}
await expectHostHasIndependentValues('2', 'first-host', '11')
await expectHostHasIndependentValues('12', 'second-host', '22')
await comfyPage.subgraph.serializeAndReload()
await expectHostHasIndependentValues('2', 'first-host', '11')
await expectHostHasIndependentValues('12', 'second-host', '22')
}
)
test(
'Nested preview exposures render through serialized chain resolution',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
test.setTimeout(45_000)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
const nestedHostProperties = await getSerializedSubgraphNodeProperties(
comfyPage,
'8'
)
expect(nestedHostProperties).not.toHaveProperty('proxyWidgets')
expect(nestedHostProperties.previewExposures).toEqual([
expect.objectContaining({
sourceNodeId: '6',
sourcePreviewName: '$$canvas-image-preview'
})
])
const nestedSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
await expect(nestedSubgraphNode).toBeVisible()
await expect(nestedSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
0
)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
.toContain('$$canvas-image-preview')
}
)
test(
'Legacy unresolvable proxy entry is omitted and quarantined on save',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(UNRESOLVABLE_PROXY_WORKFLOW)
const host = comfyPage.vueNodes.getNodeLocator('2')
await expect(host).toBeVisible()
await expect(host.getByText('missing_widget')).toHaveCount(0)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '2'))
.not.toContain('missing_widget')
const serializedProperties = await getSerializedSubgraphNodeProperties(
comfyPage,
'2'
)
expect(serializedProperties).not.toHaveProperty('proxyWidgets')
expect(serializedProperties.proxyWidgetErrorQuarantine).toEqual([
expect.objectContaining({
originalEntry: ['9999', 'missing_widget'],
reason: 'missingSourceNode',
hostValue: 'quarantined-host-value'
})
])
}
)
test(
'Promoted widget remains usable after serialize and reload',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const beforeReload = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(beforeReload).toBeVisible()
await comfyPage.subgraph.serializeAndReload()
const afterReload = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(afterReload).toBeVisible()
}
)
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(afterReload).toHaveCount(1)
await expect(afterReload).toBeVisible()
})
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
comfyPage
@@ -644,10 +413,39 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
})
})
/**
* Regression test for legacy-prefixed proxyWidget normalization.
*
* Older serialized workflows stored proxyWidget entries with prefixed widget
* names like "6: 3: string_a" instead of plain "string_a". This caused
* resolution failures during configure, resulting in missing promoted widgets.
*
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
* the promoted widget should render with the clean name "string_a".
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
*/
test.describe(
'Legacy Prefixed proxyWidget Normalization',
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
{ tag: ['@subgraph', '@widget'] },
() => {
let previousVueNodesEnabled: unknown
test.beforeEach(async ({ comfyPage }) => {
previousVueNodesEnabled = await comfyPage.settings.getSetting(
'Comfy.VueNodes.Enabled'
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
previousVueNodesEnabled
)
})
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
@@ -668,6 +466,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
@@ -683,14 +482,19 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
await expect(widgetRows.first()).not.toContainText('6: 3:')
await expect(widgetRows.nth(1)).not.toContainText('6: 3:')
for (const row of await widgetRows.all()) {
await expect(
row.getByLabel('string_a', { exact: true })
).toBeVisible()
}
})
}
)

View File

@@ -2,7 +2,6 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
import { getWav } from '@e2e/fixtures/components/AudioPreview'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -451,57 +450,4 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
)
}
)
test('Can open associated tutorial', async ({ comfyPage }) => {
const tutorialUrl = 'https://comfyanonymous.github.io/ComfyUI_examples/'
await comfyPage.page.route('**/templates/index.json', async (route) => {
const response = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'template-with-tutorial',
title: 'Template with a tutorial',
mediaType: 'audio',
mediaSubtype: 'wav',
description: 'This template has a tutorial',
tutorialUrl
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.page.route('**/templates/**.wav', async (route) => {
await route.fulfill({
status: 200,
body: getWav(),
headers: {
'Content-Type': 'image/x-wav',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
const card = comfyPage.page.getByTestId(
'template-workflow-template-with-tutorial'
)
await card.hover()
const tutorialButton = card.getByRole('button', { name: 'See a tutorial' })
await expect(tutorialButton).toBeVisible()
const popupPromise = comfyPage.page.waitForEvent('popup', { timeout: 0 })
await tutorialButton.click()
const popup = await popupPromise
expect(popup.url()).toEqual(tutorialUrl)
})
})

View File

@@ -4,29 +4,6 @@ import {
} from '@e2e/fixtures/ComfyPage'
test.describe('Vue Nodes Canvas Pan', { tag: '@vue-nodes' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test(
'Middle-click drag on a Vue node pans canvas',
{ tag: ['@canvas'] },
async ({ comfyPage, comfyMouse }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const offsetBefore = await comfyPage.canvasOps.getOffset()
await comfyMouse.middleDragFromCenter(
node,
{ x: 140, y: 90 },
{ steps: 10 }
)
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBefore)
}
)
test(
'@mobile Can pan with touch',
{ tag: '@screenshot' },

View File

@@ -507,6 +507,25 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
.toBe(initialGroupCount + 1)
})
test('should convert to group node via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
await expect
.poll(async () => {
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
'workflow>TestGroupNode'
)
return groupNodes.length
})
.toBe(1)
})
test('should convert selected nodes to subgraph via context menu', async ({
comfyPage
}) => {

View File

@@ -1,15 +1,11 @@
import { expect, mergeTests } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '@e2e/fixtures/utils/promotedWidgets'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
@@ -115,10 +111,12 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
)
.toBe(1)
await expect(firstSubgraphNode.locator('.lg-node-widgets')).toHaveCount(0)
await expect(secondSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
0
)
await expect(
firstSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await expect(
secondSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
@@ -140,44 +138,3 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
}
)
})
async function countColumns(locator: Locator) {
return await locator.locator('img').evaluateAll((images) => {
const yOffsets = images.map((image) => image.getBoundingClientRect().y)
return yOffsets.filter((yOffset) => yOffset === yOffsets[0]).length
})
}
test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
wstest(
'Image previews tile to fit node',
async ({ comfyMouse, comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await test.step('Inject multiple previews', async () => {
const file = { filename: 'example.png', type: 'input' }
const images = new Array(100).fill(file)
execution.executed('', '1', { images })
await expect(node.imageGrid.locator('img')).toHaveCount(100)
})
const { bottomRight } = node.resize
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -247,8 +247,11 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
)
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
expect(newHeaderPos.x).toBeCloseTo(loadCheckpointHeaderPos.x + 64)
expect(newHeaderPos.y).toBeCloseTo(loadCheckpointHeaderPos.y + 64)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-moved-node-touch.png'
)
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,165 +1,56 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
import {
RESIZE_HANDLES,
hasNorthEdge,
hasWestEdge
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig'
async function setupResizableNode(comfyPage: ComfyPage, title: string) {
await expect(comfyPage.vueNodes.getNodeByTitle(title)).toHaveCount(1)
const node = await comfyPage.vueNodes.getFixtureByTitle(title)
const box = await node.selectAndGetBox()
return { node, box }
}
test.describe('Vue Node Resizing', { tag: '@vue-nodes' }, () => {
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
// Get a Vue node fixture
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const initialBox = await node.boundingBox()
if (!initialBox) throw new Error('Node bounding box not found')
test.describe(
'Vue Node Resizing',
{ tag: ['@vue-nodes', '@canvas', '@node'] },
() => {
let originalMinimapVisible: boolean | undefined
// Select the node first (this was causing the bug)
await node.header.click()
// Minimap overlays the canvas and intercepts pointer events that land in
// its hit area during resize drags, so disable it for this suite. Capture
// and restore the prior value to avoid leaking the override to other specs
// that run on the same user-data-dir.
test.beforeEach(async ({ comfyPage }) => {
originalMinimapVisible = await comfyPage.settings.getSetting<boolean>(
'Comfy.Minimap.Visible'
)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.canvasOps.resetView()
})
// Get position after selection
const selectedBox = await node.boundingBox()
if (!selectedBox)
throw new Error('Node bounding box not found after select')
test.afterEach(async ({ comfyPage }) => {
if (originalMinimapVisible !== undefined) {
await comfyPage.settings.setSetting(
'Comfy.Minimap.Visible',
originalMinimapVisible
)
}
})
// Verify position unchanged after selection
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
const { node, box: initialBox } = await setupResizableNode(
comfyPage,
'Load Checkpoint'
)
// Now resize from bottom-right corner
const resizeStartX = selectedBox.x + selectedBox.width - 5
const resizeStartY = selectedBox.y + selectedBox.height - 5
await node.expectAnchoredAt(initialBox)
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
await comfyPage.page.mouse.up()
await node.resizeFromCorner('SE', 50, 30)
// Position should NOT have changed (the bug was position drift)
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
await node.expectAnchoredAt(initialBox)
await expect.poll(node.pollWidth).toBeGreaterThan(initialBox.width)
await expect.poll(node.pollHeight).toBeGreaterThan(initialBox.height)
})
const cornerCases = RESIZE_HANDLES.map((h) => ({
corner: h.corner,
dragX: hasWestEdge(h.corner) ? -50 : 50,
dragY: hasNorthEdge(h.corner) ? -40 : 40
}))
test.describe('corner resize directions', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner}: size increases and correct edges shift`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(node.pollWidth).toBeGreaterThan(box.width)
await expect.poll(node.pollHeight).toBeGreaterThan(box.height)
if (hasWestEdge(corner)) {
await expect.poll(node.pollLeftEdge).toBeLessThan(box.x)
} else {
await expect.poll(node.pollLeftEdge).toBeCloseTo(box.x, 0)
}
if (hasNorthEdge(corner)) {
await expect.poll(node.pollTopEdge).toBeLessThan(box.y)
} else {
await expect.poll(node.pollTopEdge).toBeCloseTo(box.y, 0)
}
})
})
})
test.describe('opposite edge anchoring', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner} resize keeps opposite corner fixed`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const pollAnchorX = hasWestEdge(corner)
? node.pollRightEdge
: node.pollLeftEdge
const pollAnchorY = hasNorthEdge(corner)
? node.pollBottomEdge
: node.pollTopEdge
const anchorX = hasWestEdge(corner) ? box.x + box.width : box.x
const anchorY = hasNorthEdge(corner) ? box.y + box.height : box.y
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(pollAnchorX).toBeCloseTo(anchorX, 0)
await expect.poll(pollAnchorY).toBeCloseTo(anchorY, 0)
})
})
})
test.describe('minimum size enforcement', () => {
test('SW resize clamps width, keeping right edge fixed', async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const rightEdge = box.x + box.width
await node.resizeFromCorner('SW', box.width + 100, 0)
await expect.poll(node.pollRightEdge).toBeCloseTo(rightEdge, 0)
await expect.poll(node.pollWidth).toBeGreaterThanOrEqual(MIN_NODE_WIDTH)
})
test('NE resize clamps height at its lower bound', async ({
comfyPage
}) => {
const { node } = await setupResizableNode(comfyPage, 'KSampler')
// Default nodes render at content-minimum height; grow from SE so NE
// has room to shrink back down to the clamp.
await node.resizeFromCorner('SE', 0, 200)
const expandedBox = await node.boundingBox()
if (!expandedBox)
throw new Error('Node bounding box not found after SE grow')
const bottomEdge = expandedBox.y + expandedBox.height
// Overdrag once to hit the clamp, then again to prove further dragging
// does not shrink past the minimum (idempotent clamp).
await node.resizeFromCorner('NE', 0, expandedBox.height + 100)
const clampedHeight = (await node.boundingBox())?.height
if (clampedHeight === undefined)
throw new Error('Node bounding box not found after NE clamp')
expect(clampedHeight).toBeLessThan(expandedBox.height)
await node.resizeFromCorner('NE', 0, 200)
await expect.poll(node.pollHeight).toBeCloseTo(clampedHeight, 0)
await expect.poll(node.pollBottomEdge).toBeCloseTo(bottomEdge, 0)
})
})
}
)
// Size should have increased
await expect
.poll(async () => (await node.boundingBox())?.width)
.toBeGreaterThan(initialBox.width)
await expect
.poll(async () => (await node.boundingBox())?.height)
.toBeGreaterThan(initialBox.height)
})
})

View File

@@ -5,10 +5,6 @@ import {
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const getFirstClipNode = (comfyPage: ComfyPage) =>
comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first()
@@ -58,23 +54,4 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
await textarea.click({ button: 'right' })
await expect(vueContextMenu).toBeVisible()
})
test(
'Middle-click drag on textarea should pan canvas',
{ tag: ['@canvas', '@widget'] },
async ({ comfyPage, comfyMouse }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const offsetBefore = await comfyPage.canvasOps.getOffset()
await comfyMouse.middleDragFromCenter(
textarea,
{ x: 140, y: 90 },
{ steps: 10 }
)
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBefore)
}
)
})

View File

@@ -9,23 +9,25 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_a', '', () => {})
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_b', '', () => {})
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_c', '', () => {})
node.widgets!.splice(0, 0, {
...node.widgets![0],
name: 'added_widget_3'
})
})
await expect(loadCheckpointNode).toHaveCount(4)
})

View File

@@ -285,11 +285,11 @@ quarantine.
## PromotionStore
`PromotionStore` has been removed. Canonical value-widget exposure is
represented by linked `SubgraphInput`s. Canonical preview exposure is
represented by host-scoped `properties.previewExposures` /
`PreviewExposureStore`. Legacy `properties.proxyWidgets` is migration input only
and must not be reintroduced as runtime authority.
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
runtime compatibility/index layer for existing consumers, but it is not
serialized authority, must not create promotions without linked
`SubgraphInput`s, and should be removed once consumers query the standard graph
interface directly.
## Considered options
@@ -325,5 +325,4 @@ for existing workflow consumers that still assume array order.
- Primitive fanout repair is more complex, but avoids breaking common existing
workflows.
- UI code must migrate with the runtime migration to avoid mixed identity states.
- `PromotionStore` is removed; callers query linked inputs or preview exposures
directly.
- `PromotionStore` has a clear removal path.

View File

@@ -6,17 +6,16 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
## 1. What's Already Extracted
Five stores extract entity state out of class instances into centralized,
queryable registries. Promoted value-widget topology is no longer a store; ADR
0009 represents it as ordinary linked `SubgraphInput` state.
Six stores extract entity state out of class instances into centralized, queryable registries:
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------------------- | --------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
| PromotionStore | `SubgraphNode` | `graphId → nodeId → source[]` | `"${sourceNodeId}:${widgetName}"` | Ref-counted promotion entries |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
@@ -100,39 +99,62 @@ graph LR
| Behavior on class | **No** | Drawing, events, callbacks still on widget |
| Module-scope store access | **No** | `useWidgetValueStore()` called from domain object |
## 3. Linked promoted widgets and preview exposures
## 3. PromotionStore
`PromotionStore` was removed by ADR 0009. Promoted value widgets are represented
by linked `SubgraphInput`s, and display-only previews are represented by
host-scoped `properties.previewExposures` / `PreviewExposureStore` entries.
Legacy `properties.proxyWidgets` is load-time migration input only.
**File:** `src/stores/promotionStore.ts`
### Runtime shape
Extracts subgraph widget promotion decisions into a centralized, ref-counted registry.
```diagram
╭────────────────╮ ╭──────────────────╮ ╭────────────────╮
│ SubgraphInput │────▶│ Interior slot │────▶│ Source widget │
╰────────────────╯ ╰──────────────────╯ ╰────────────────╯
### State Shape
╭────────────────╮ ╭──────────────────────╮
│ Subgraph host │────▶│ PreviewExposureStore │
╰────────────────╯ ╰──────────────────────╯
```
graphPromotions: Map<UUID, Map<NodeId, PromotedWidgetSource[]>>
│ │ │
graphId subgraphNodeId ordered promotion entries
graphRefCounts: Map<UUID, Map<string, number>>
│ │ │
graphId entryKey count of nodes promoting this widget
```
`PromotedWidgetViewManager`
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
synthetic widget views derived from linked subgraph inputs. It does not sit on
top of a promotion registry.
### Ref-Counting for O(1) Queries
The store maintains a parallel ref-count map. When a widget is promoted on a SubgraphNode, the ref count for that entry key increments. When demoted, it decrements. This enables:
```ts
isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName }): boolean
// O(1) lookup: refCounts.get(key) > 0
```
Without ref counting, this query would require scanning all SubgraphNodes in the graph.
### View Reconciliation Layer
`PromotedWidgetViewManager` (`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) sits between the store and the UI:
```mermaid
graph LR
PS["PromotionStore
(data)"] -->|"entries"| VM["PromotedWidgetViewManager
(reconciliation)"] -->|"stable views"| PV["PromotedWidgetView
(proxy widget)"]
PV -->|"resolveDeepest()"| CW["Concrete Widget
(leaf node)"]
PV -->|"reads value"| WVS["WidgetValueStore"]
```
The manager maintains a `viewCache` to preserve object identity across updates — a reconciliation pattern similar to React's virtual DOM diffing.
### ECS Alignment
| Aspect | ECS-like | Why |
| ----------------------------- | --------- | ------------------------------------------------------------- |
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
| Aspect | ECS-like | Why |
| ---------------------------------- | --------- | ----------------------------------------------------------------------- |
| Data separated from views | Yes | Store holds entries; ViewManager holds UI proxies |
| Ref-counted queries | Yes | Efficient global state queries without scanning |
| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` |
| View reconciliation | Partially | ViewManager is a system-like layer, but tightly coupled to SubgraphNode |
| SubgraphNode drives mutations | **No** | Entity class calls `store.setPromotions()` directly |
| BaseWidget queries store in render | **No** | `getOutlineColor()` calls `isPromotedByAny()` every frame |
## 4. LayoutStore (CRDT)
@@ -186,8 +208,8 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
1. **Plain data objects**: `WidgetState`, `DomWidgetState`, CRDT maps are all methods-free data
2. **Centralized registries**: Each store is a `Map<key, data>` — structurally identical to an ECS component store
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PreviewExposureStore)
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PromotionStore)
4. **Query APIs**: `getWidget()`, `isPromotedByAny()`, `getNodeWidgets()` — system-like queries
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
### What's Missing vs Full ECS
@@ -200,7 +222,7 @@ graph TD
H2["Plain data components
(WidgetState, LayoutMap)"]
H3["Query APIs
(getWidget, preview exposures)"]
(getWidget, isPromotedByAny)"]
H4["Graph-scoped lifecycle"]
H5["Partial position extraction
(LayoutStore)"]
@@ -227,12 +249,13 @@ graph TD
Each store invents its own identity scheme:
| Store | Key Format | Entity ID Used | Type-Safe? |
| ---------------- | --------------------------- | ----------------------- | ---------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| Store | Key Format | Entity ID Used | Type-Safe? |
| ---------------- | --------------------------------- | ----------------------- | ---------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
| PromotionStore | `"${sourceNodeId}:${widgetName}"` | NodeId (string-coerced) | No |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
For promoted value widgets, ADR 0009 narrows the target key to host boundary
@@ -266,6 +289,7 @@ graph TD
- value → WidgetValueStore
- label → WidgetValueStore
- disabled → WidgetValueStore
- promotion status → PromotionStore
- DOM pos/vis → DomWidgetStore"]
W_rem["Remains on class:
- _node back-ref
@@ -309,8 +333,7 @@ graph TD
subgraph Subgraph["Subgraph (node component)"]
S_ext["Extracted:
- value exposure → linked inputs
- preview exposure → PreviewExposureStore"]
- promotions → PromotionStore"]
S_rem["Remains on class:
- name, description
- inputs[], outputs[]
@@ -337,15 +360,15 @@ graph TD
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
### Priority Order for Extraction

View File

@@ -250,5 +250,5 @@ interactions (e.g., `comfyPage.settings.setSetting`, `comfyPage.nodeOps`,
```bash
pnpm test:browser:local # Run all E2E tests
pnpm test:browser:local --ui # Interactive UI mode
pnpm test:browser:local -- --ui # Interactive UI mode
```

View File

@@ -30,9 +30,7 @@ See `docs/testing/*.md` for detailed patterns.
## Running Tests
```bash
pnpm test:unit # Run all unit tests
pnpm test:unit path/to/file # Filter by substring of test file path
pnpm test:unit foo.test.ts -t "name" # Filter by test name (regex; it()/test() only, not describe())
pnpm test:unit # Run all unit tests
pnpm test:unit -- path/to/file # Run specific test
pnpm test:unit -- --watch # Watch mode
```
Do not use the `--` separator before vitest args; pnpm forwards extra args automatically, and `--` mangles quoted args (e.g. `-t "two words"`) on Windows PowerShell.

View File

@@ -43,10 +43,10 @@ To run the tests locally:
pnpm test:unit
# Run a specific test file
pnpm test:unit src/path/to/file.test.ts
pnpm test:unit -- src/path/to/file.test.ts
# Run unit tests in watch mode
pnpm test:unit --watch
pnpm test:unit -- --watch
```
Refer to the specific guides for more detailed information on each testing type.

View File

@@ -145,7 +145,9 @@ export default defineConfig([
eslintConfigPrettier,
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
storybookConfigs['flat/recommended'],
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.recommended,
// @ts-expect-error Type incompatibility between import-x plugin and ESLint config types
importX.flatConfigs.typescript,
{
plugins: {
@@ -371,8 +373,7 @@ export default defineConfig([
files: [
'src/base/**/*.{ts,vue}',
'src/platform/**/*.{ts,vue}',
'src/workbench/**/*.{ts,vue}',
'src/world/**/*.{ts,vue}'
'src/workbench/**/*.{ts,vue}'
],
rules: {
'import-x/no-restricted-paths': [
@@ -400,12 +401,6 @@ export default defineConfig([
from: './src/renderer/**',
message:
'workbench/ cannot import from renderer/ (violates layer architecture: base → platform → workbench → renderer)'
},
{
target: './src/world/**',
from: './src/lib/litegraph/**',
message:
'src/world/ must remain free of litegraph dependencies. The world layer owns canonical entity identity and must not depend on litegraph types or values.'
}
]
}

View File

@@ -43,6 +43,7 @@ const config: KnipConfig = {
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
],
ignore: [

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.7",
"version": "1.45.14",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -97,7 +97,7 @@
"axios": "catalog:",
"chart.js": "^4.5.0",
"cva": "catalog:",
"dompurify": "catalog:",
"dompurify": "^3.2.5",
"dotenv": "catalog:",
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
@@ -193,7 +193,7 @@
"unplugin-icons": "catalog:",
"unplugin-typegpu": "catalog:",
"unplugin-vue-components": "catalog:",
"uuid": "catalog:",
"uuid": "^11.1.0",
"vite": "catalog:",
"vite-plugin-dts": "catalog:",
"vite-plugin-html": "catalog:",
@@ -206,8 +206,8 @@
"zod-to-json-schema": "catalog:"
},
"engines": {
"node": ">=25",
"pnpm": ">=11.3"
"node": "24.x",
"pnpm": ">=11"
},
"packageManager": "pnpm@11.3.0"
"packageManager": "pnpm@11.1.1"
}

View File

@@ -406,7 +406,9 @@
--secondary-background-selected
);
--component-node-widget-background-selected: var(--color-charcoal-100);
--component-node-widget-background-disabled: var(--color-charcoal-800);
--component-node-widget-background-disabled: var(
--color-alpha-charcoal-600-30
);
--component-node-widget-background-highlighted: var(--color-smoke-800);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-600);
@@ -1892,17 +1894,3 @@ audio.comfy-audio.empty-audio-widget {
300% 14px;
background-attachment: local, local, scroll, scroll;
}
/*
PrimeVue overlays teleport to body. When a Reka modal dialog is open it sets
body { pointer-events: none } via DismissableLayer, which propagates to the
body-portaled overlays and makes them unclickable. PrimeVue's own Dialog
sets pointer-events inline, but Select / ColorPicker / Popover / Autocomplete
overlays do not, so they need to opt in here.
*/
.p-select-overlay,
.p-colorpicker-panel,
.p-popover,
.p-autocomplete-overlay {
pointer-events: auto;
}

2519
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,13 @@ catalog:
'@astrojs/sitemap': ^3.7.1
'@astrojs/vue': ^5.0.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^10.0.1
'@eslint/js': ^9.39.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.3
'@iconify/tailwind4': ^1.2.0
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
@@ -66,33 +66,33 @@ catalog:
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.15.2
axios: ^1.13.5
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.4.5
dompurify: ^3.3.1
dotenv: ^16.4.5
eslint: ^10.4.0
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.2
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.59.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.1
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6
globals: ^16.5.0
gsap: ^3.14.2
happy-dom: ^20.8.9
happy-dom: ^20.0.11
husky: ^9.1.7
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.14.1
knip: ^6.3.1
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
@@ -108,29 +108,28 @@ catalog:
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
reka-ui: 2.5.0
reka-ui: ^2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.3
typescript-eslint: ^8.60.0
typescript-eslint: ^8.49.0
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
uuid: ^11.1.1
vee-validate: ^4.15.1
vite: ^8.0.13
vite: ^8.0.0
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.0.16
vue: ^3.5.34
vue: ^3.5.13
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
@@ -161,9 +160,3 @@ overrides:
vite: 'catalog:'
'@tiptap/pm': 2.27.2
'@types/eslint': '-'
#Security overrides
lodash: ^4.18.0
yaml: ^2.8.3
minimatch@^9.0.0: ^9.0.7
minimatch@^10.0.0: ^10.2.3
ajv@^8.0.0: ^8.18.0

View File

@@ -1,75 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
isMiddleButtonEvent,
isMiddleButtonHeld,
isMiddlePointerInput
} from '@/base/pointerUtils'
describe('pointerUtils', () => {
describe('isMiddlePointerInput', () => {
it.for([
{
name: 'accepts a middle-button pointerdown',
event: new PointerEvent('pointerdown', { button: 1, buttons: 4 }),
expected: true
},
{
name: 'accepts strict middle-only held buttons',
event: new PointerEvent('pointermove', { buttons: 4 }),
expected: true
},
{
name: 'rejects chorded pointerdown when middle is only incidentally held',
event: new PointerEvent('pointerdown', { button: 0, buttons: 5 }),
expected: false
}
])('$name', ({ event, expected }) => {
expect(isMiddlePointerInput(event)).toBe(expected)
})
})
describe('isMiddleButtonHeld', () => {
it.for([
{
name: 'accepts the middle-button bit alone',
event: new PointerEvent('pointermove', { buttons: 4 }),
expected: true
},
{
name: 'accepts chorded moves that include the middle-button bit',
event: new PointerEvent('pointermove', { buttons: 5 }),
expected: true
},
{
name: 'accepts pointercancel when the middle-button bit is still held',
event: new PointerEvent('pointercancel', { buttons: 4 }),
expected: true
},
{
name: 'rejects primary-button-only moves',
event: new PointerEvent('pointermove', { buttons: 1 }),
expected: false
}
])('$name', ({ event, expected }) => {
expect(isMiddleButtonHeld(event)).toBe(expected)
})
})
describe('isMiddleButtonEvent', () => {
it.for([
{
name: 'accepts a middle-button pointerup',
event: new PointerEvent('pointerup', { button: 1 }),
expected: true
},
{
name: 'rejects a non-middle changed button even when middle is held',
event: new MouseEvent('auxclick', { button: 2, buttons: 4 }),
expected: false
}
])('$name', ({ event, expected }) => {
expect(isMiddleButtonEvent(event)).toBe(expected)
})
})
})

View File

@@ -2,14 +2,21 @@
* Utilities for pointer event handling
*/
export function isMiddlePointerInput(event: MouseEvent): boolean {
return event.button === 1 || event.buttons === 4
}
/**
* Checks if a pointer or mouse event is a middle button input
* @param event - The pointer or mouse event to check
* @returns true if the event is from the middle button/wheel
*/
export function isMiddlePointerInput(
event: PointerEvent | MouseEvent
): boolean {
if ('button' in event && event.button === 1) {
return true
}
export function isMiddleButtonHeld(event: MouseEvent): boolean {
return (event.buttons & 4) === 4
}
if ('buttons' in event && typeof event.buttons === 'number') {
return event.buttons === 4
}
export function isMiddleButtonEvent(event: MouseEvent): boolean {
return event.button === 1
return false
}

View File

@@ -8,9 +8,7 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetEntityId } from '@/world/entityIds'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -30,6 +28,7 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
import { renameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -48,8 +47,26 @@ const hoveringSelectable = ref(false)
workflowStore.activeWorkflow?.changeTracker?.reset()
const resolvedInputs = useResolvedSelectedInputs()
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) {
return {
nodeId,
widgetName,
subLabel: t('linearMode.builder.unknownWidget')
}
}
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
canRename: true
}
})
)
const outputsWithState = computed<[NodeId, string][]>(() =>
appModeStore.selectedOutputs.map((nodeId) => [
nodeId,
@@ -57,6 +74,16 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
function inlineRenameInput(
nodeId: NodeId,
widgetName: string,
newLabel: string
) {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) return
renameWidget(widget, node, newLabel)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -75,26 +102,22 @@ function getHovered(
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
}
function getNodeBounding(nodeId: NodeId) {
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const node = app.rootGraph.getNodeById(nodeId)
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node) return
const titleOffset =
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
}
function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
if (entry.status !== 'resolved') return undefined
const { node, widget } = entry
if (!widgetName)
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
const marginX = margin ?? BaseWidget.margin
@@ -110,11 +133,6 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
}
}
function removeSelectedEntityId(entityId: WidgetEntityId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
}
function handleDown(e: MouseEvent) {
const [node] = getHovered(e) ?? []
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
@@ -139,11 +157,14 @@ function handleClick(e: MouseEvent) {
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const entityId = widget.entityId
if (!entityId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index === -1)
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -152,7 +173,7 @@ function nodeToDisplayTuple(
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
return [
n.id,
getNodeBounding(n.id),
getBounding(n.id),
appModeStore.selectedOutputs.some((id) => n.id === id)
]
}
@@ -170,13 +191,10 @@ const renderedOutputs = computed(() => {
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
resolvedInputs.value.map(
(entry) =>
[entry.entityId, getWidgetBounding(entry)] as [
string,
MaybeRef<BoundStyle> | undefined
]
)
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
`${nodeId}: ${widgetName}`,
getBounding(nodeId, widgetName)
])
)
</script>
<template>
@@ -220,28 +238,30 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<template v-for="entry in resolvedInputs" :key="entry.entityId">
<IoItem
v-if="entry.status === 'resolved'"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="entry.widget.label ?? entry.displayName"
:sub-title="entry.node.title"
can-rename
:remove="() => appModeStore.removeSelectedInput(entry.widget)"
@rename="renameWidget(entry.widget, entry.node, $event)"
/>
<IoItem
v-else
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="entry.displayName"
:sub-title="t('linearMode.builder.unknownWidget')"
:remove="() => removeSelectedEntityId(entry.entityId)"
/>
</template>
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
canRename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="label ?? widgetName"
:sub-title="subLabel"
:can-rename="canRename"
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
@rename="inlineRenameInput(nodeId, widgetName, $event)"
/>
</DraggableList>
</PropertiesAccordionItem>
<div

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
@@ -22,12 +23,15 @@ import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
nodeId: NodeId
widgetName: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
@@ -45,25 +49,31 @@ const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
appModeStore.updateInputConfig(widget, config)
const { onPointerDown } = useAppModeWidgetResizing(
(nodeId, widgetName, config) =>
appModeStore.updateInputConfig(nodeId, widgetName, config)
)
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const resolvedInputs = useResolvedSelectedInputs()
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { entityId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName, config]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
@@ -72,7 +82,15 @@ const mappedSelections = computed((): WidgetEntry[] => {
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.entityId === entityId
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return (
isPromotedWidgetView(widget) &&
widget.sourceNodeId == storeNodeId &&
widget.sourceWidgetName === vueWidget.storeName
)
})
if (!matchingWidget) return []
@@ -81,7 +99,9 @@ const mappedSelections = computed((): WidgetEntry[] => {
return [
{
key: entityId,
key: `${nodeId}:${widgetName}`,
nodeId,
widgetName,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
@@ -148,7 +168,14 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
v-for="{
key,
nodeId,
widgetName,
persistedHeight,
nodeData,
action
} in mappedSelections"
:key
:class="
cn(
@@ -196,7 +223,8 @@ defineExpose({ handleDragDrop })
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () => appModeStore.removeSelectedInput(action.widget)
command: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
}
]"
>
@@ -225,7 +253,7 @@ defineExpose({ handleDragDrop })
)
"
:inert="builderMode || undefined"
@pointerdown.capture="(e) => onPointerDown(action.widget, e)"
@pointerdown.capture="(e) => onPointerDown(nodeId, widgetName, e)"
>
<DropZone
:on-drag-over="nodeData.onDragOver"

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