Compare commits

..

34 Commits

Author SHA1 Message Date
pythongosssss
711371d9c5 expand node search box V2 e2e coverage
- extend search v2 fixtures
 - add additional tests
 - add data-testid for targeting elements
 - rework tests to work with new menu UI
2026-03-27 07:47:11 -07:00
pythongosssss
c00e285768 fix tests 2026-03-25 08:40:27 -07:00
pythongosssss
8f41bc7527 update font size 2026-03-24 08:47:31 -07:00
pythongosssss
11b62c48e3 fix 2026-03-24 08:37:38 -07:00
pythongosssss
cc3d3f1d25 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-24 08:31:57 -07:00
pythongosssss
92e65aaaa7 remove dead code 2026-03-23 07:09:18 -07:00
pythongosssss
f82f8624e1 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-20 14:53:55 -07:00
pythongosssss
c46316d248 feedback 2026-03-20 14:51:33 -07:00
pythongosssss
8e5dc15e5d Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-19 12:45:01 -07:00
pythongosssss
da2fedebcf fix incorrectly collapsing parent category to root 2026-03-18 09:19:47 -07:00
pythongosssss
2a531ff80b fix test 2026-03-18 06:58:13 -07:00
pythongosssss
b6234b96af rework expand/collapse, prevent requiring double left arrow to collapse 2026-03-18 06:55:29 -07:00
pythongosssss
bd66617d3f Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-18 04:27:25 -07:00
pythongosssss
98eac41f07 fix highlighting cross word and remove padding 2026-03-17 08:00:00 -07:00
pythongosssss
307a1c77c0 ensure canvas gets focus after ghost placement 2026-03-17 07:40:28 -07:00
pythongosssss
bbd1e60f7b cap description size 2026-03-17 07:19:47 -07:00
pythongosssss
9100058fc1 fix dialog height 2026-03-17 06:53:47 -07:00
pythongosssss
04c00aadd8 remove left categories and add as filter buttons 2026-03-16 09:05:05 -07:00
pythongosssss
2f1615c505 fix test 2026-03-16 08:36:53 -07:00
pythongosssss
cf4dfceaee - fix dialog stealing focus
- fix tab vs click chevron focus visibility
2026-03-16 08:21:46 -07:00
pythongosssss
dbb70323bf fix bad merge 2026-03-16 08:07:41 -07:00
pythongosssss
6689510591 fix 2026-03-16 08:06:36 -07:00
pythongosssss
82e62694a9 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-16 07:53:48 -07:00
pythongosssss
d49f263536 add e2e test for moving ghost 2026-03-16 07:47:00 -07:00
pythongosssss
d30bb01b4b fix: prevent other nodes intercepting ghost node capture 2026-03-16 06:50:11 -07:00
pythongosssss
320cd82f0d rename custom to extensions 2026-03-16 06:12:38 -07:00
pythongosssss
8a30211bea - dont clear category + allow category searching
- loop focus in dialog
2026-03-16 06:10:26 -07:00
pythongosssss
12fd0981a8 hide extensions/custom categories when no custom nodes 2026-03-12 13:51:35 -07:00
pythongosssss
0772f2a7fe - make test more specific
- fix expanding after manual categ collapse
2026-03-12 13:19:07 -07:00
pythongosssss
08666d8e81 rabbit
- update plural item selected entry
- update mock bookmarts to default empty
- fix test testing already sorted data
- prevent autoExpand already expanded
- fix aria role
- add test + fix path matching
2026-03-12 05:05:42 -07:00
pythongosssss
d18243e085 Merge remote-tracking branch 'origin/main' into pysssss/node-search-feedback 2026-03-12 04:39:45 -07:00
pythongosssss
3cba424e52 additional search feedback
- improved keyboard navigation and aria
- fixed alignment of elements
- updated fonts and sizes
- more tidy + nits
- tests
2026-03-12 04:18:20 -07:00
pythongosssss
0f3b2e0455 fix test 2026-03-10 08:19:42 -07:00
pythongosssss
fd31f9d0ed additional node search updates
- add root filter buttons
- replace input/output selection with popover
- replace price badge with one from node header
- fix bug with hovering selecting item under mouse automatically
- fix tailwind merge with custom sizes removing them
- general tidy/refactor/test
2026-03-10 07:36:40 -07:00
196 changed files with 3205 additions and 11894 deletions

View File

@@ -18,20 +18,12 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
## System Context
| Item | Value |
| -------------- | --------------------------------------------------------------------------- |
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes |
| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) |
| Tracking dir | `~/temp/backport-session/` |
## CI Safety Rules
**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs.
- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails.
- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass.
- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step.
| Item | Value |
| -------------- | ------------------------------------------------- |
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
| Tracking dir | `~/temp/backport-session/` |
## Branch Scope Rules
@@ -116,15 +108,11 @@ git fetch origin TARGET_BRANCH
# Quick smoke check: does the branch build?
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
git worktree remove /tmp/verify-TARGET --force
```
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
### Never Admin-Merge Without CI
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
## Continuous Backporting Recommendation

View File

@@ -19,44 +19,23 @@ done
# Wait 3 minutes for automation
sleep 180
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
# Check which got auto-PRs
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
```
> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those.
## Step 2: Wait for CI & Merge Clean Auto-PRs
Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures:
## Step 2: Review & Merge Clean Auto-PRs
```bash
# Wait for CI to complete (~45 minutes for full suite)
sleep 2700
# Check which PRs are still open (CI may have failed, or auto-merge succeeded)
STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number')
RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt)
# For PRs still open, check CI status
for pr in $STILL_OPEN_PRS; do
CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length')
CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length')
if [ "$CI_FAILED" != "0" ]; then
# CI failed — collect details for triage
echo "PR #$pr — CI FAILED:"
gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"'
elif [ "$CI_PENDING" != "0" ]; then
echo "PR #$pr — CI still running ($CI_PENDING checks pending)"
else
# All checks passed but didn't auto-merge (race condition or label issue)
gh pr merge $pr --squash --admin
sleep 3
fi
for pr in $AUTO_PRS; do
# Check size
gh pr view $pr --json title,additions,deletions,changedFiles \
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
# Admin merge
gh pr merge $pr --squash --admin
sleep 3
done
```
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
## Step 3: Manual Worktree for Conflicts
```bash
@@ -84,13 +63,6 @@ for PR in ${CONFLICT_PRS[@]}; do
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
--title "[backport TARGET] TITLE (#$PR)" \
--body "Backport of #$PR..." | grep -oP '\d+$')
# Wait for CI before merging — NEVER admin-merge without CI passing
echo "Waiting for CI on PR #$NEW_PR..."
gh pr checks $NEW_PR --watch --fail-fast || {
echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage"
continue
}
gh pr merge $NEW_PR --squash --admin
sleep 3
done
@@ -110,7 +82,7 @@ After completing all PRs in a wave for a target branch:
git fetch origin TARGET_BRANCH
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
git worktree remove /tmp/verify-TARGET --force
```
@@ -160,8 +132,7 @@ git rebase origin/TARGET_BRANCH
# Resolve new conflicts
git push --force origin backport-$PR-to-TARGET
sleep 20 # Wait for GitHub to recompute merge state
# Wait for CI after rebase before merging
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
gh pr merge $PR --squash --admin
```
## Lessons Learned
@@ -175,31 +146,5 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
8. **Always validate JSON** after resolving locale file conflicts
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
12. **Never admin-merge without CI**`--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
## CI Failure Triage
When CI fails on a backport PR, present failures to the user using this template:
```markdown
### PR #XXXX — CI Failed
- **Failing check:** test / lint / typecheck
- **Error:** (summary of the failure message)
- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch
- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites
```
Common failure categories:
| Category | Example | Resolution |
| --------------------------- | ---------------------------------------- | ----------------------------------------- |
| Test without implementation | Test references function not on branch | Backport the implementation PR first |
| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip |
| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch |
| Flaky test | Passes on retry | Re-run CI, merge if green on retry |
| Type error | Interface changed on main but not branch | May need manual adaptation |
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.

View File

@@ -5,9 +5,9 @@
Maintain `execution-log.md` with per-branch tables:
```markdown
| PR# | Title | CI Status | Status | Backport PR | Notes |
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
| PR# | Title | Status | Backport PR | Notes |
| ----- | ----- | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
```
## Wave Verification Log
@@ -19,7 +19,6 @@ Track verification results per wave:
- PRs merged: #A, #B, #C
- Typecheck: ✅ Pass / ❌ Fail
- Unit tests: ✅ Pass / ❌ Fail
- Issues found: (if any)
- Human review needed: (list any non-trivial conflict resolutions)
```
@@ -42,11 +41,6 @@ Track verification results per wave:
| PR# | Branch | Conflict Type | Resolution Summary |
## CI Failure Report
| PR# | Branch | Failing Check | Error Summary | Cause | Resolution |
| --- | ------ | ------------- | ------------- | ----- | ---------- |
## Automation Performance
| Metric | Value |

View File

@@ -39,7 +39,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -87,7 +87,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -180,7 +180,7 @@ jobs:
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
git fetch origin perf-data --depth=1
mkdir -p temp/perf-history
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
done
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,6 +1,6 @@
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { config as dotenvConfig } from 'dotenv'
import dotenv from 'dotenv'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
@@ -11,7 +11,7 @@ import { defineConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
dotenvConfig()
dotenv.config()
const projectRoot = fileURLToPath(new URL('.', import.meta.url))

View File

@@ -1,2 +0,0 @@
dist/
.astro/

View File

@@ -1,24 +0,0 @@
import { defineConfig } from 'astro/config'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
integrations: [vue()],
vite: {
plugins: [tailwindcss()]
},
build: {
assetsPrefix: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: undefined
},
i18n: {
locales: ['en', 'zh-CN'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false
}
}
})

View File

@@ -1,80 +0,0 @@
{
"name": "@comfyorg/website",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@comfyorg/design-system": "workspace:*",
"@vercel/analytics": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"@astrojs/vue": "catalog:",
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
}
}
}
}

View File

@@ -1 +0,0 @@
/// <reference types="astro/client" />

View File

@@ -1,2 +0,0 @@
@import 'tailwindcss';
@import '@comfyorg/design-system/css/base.css';

View File

@@ -1,9 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "astro.config.mjs"]
}

View File

@@ -75,7 +75,7 @@ For tests that specifically need to test release functionality, see the example
**Always use UI mode for development:**
```bash
pnpm test:browser:local --ui
pnpm exec playwright test --ui
```
UI mode features:
@@ -91,8 +91,29 @@ UI mode features:
For CI or headless testing:
```bash
pnpm test:browser:local # Run all tests
pnpm test:browser:local widget.spec.ts # Run specific test file
pnpm exec playwright test # Run all tests
pnpm exec playwright test widget.spec.ts # Run specific test file
```
### Local Development Config
For debugging, you can try adjusting these settings in `playwright.config.ts`:
```typescript
export default defineConfig({
// VERY HELPFUL: Skip screenshot tests locally
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
timeout: 30000, // Longer timeout for breakpoints
use: {
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
video: 'on' // Always record video (CI uses 'retain-on-failure')
},
})
```
## Test Structure
@@ -364,7 +385,7 @@ export default defineConfig({
Option 2 - Generate local baselines for comparison:
```bash
pnpm test:browser:local --update-snapshots
pnpm exec playwright test --update-snapshots
```
### Creating New Screenshot Baselines

View File

@@ -1,817 +0,0 @@
{
"id": "9ae6082b-c7f4-433c-9971-7a8f65a3ea65",
"revision": 0,
"last_node_id": 61,
"last_link_id": 70,
"nodes": [
{
"id": 35,
"type": "MarkdownNote",
"pos": [-424.0076397768001, 199.99406275798367],
"size": [510, 774],
"flags": {
"collapsed": false
},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Model link",
"properties": {},
"widgets_values": [
"## Report workflow issue\n\nIf you found any issues when running this workflow, [report template issue here](https://github.com/Comfy-Org/workflow_templates/issues)\n\n\n## Model links\n\n**text_encoders**\n\n- [qwen_3_4b.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors)\n\n**loras**\n\n- [pixel_art_style_z_image_turbo.safetensors](https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors)\n\n**diffusion_models**\n\n- [z_image_turbo_bf16.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors)\n\n**vae**\n\n- [ae.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors)\n\n\nModel Storage Location\n\n```\n📂 ComfyUI/\n├── 📂 models/\n│ ├── 📂 text_encoders/\n│ │ └── qwen_3_4b.safetensors\n│ ├── 📂 loras/\n│ │ └── pixel_art_style_z_image_turbo.safetensors\n│ ├── 📂 diffusion_models/\n│ │ └── z_image_turbo_bf16.safetensors\n│ └── 📂 vae/\n│ └── ae.safetensors\n```\n"
],
"color": "#432",
"bgcolor": "#000"
},
{
"id": 9,
"type": "SaveImage",
"pos": [569.9875743118757, 199.99406275798367],
"size": [780, 660],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 62
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["z-image-turbo"]
},
{
"id": 57,
"type": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
"pos": [128.01215102992103, 199.99406275798367],
"size": [400, 470],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "prompt",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [62]
}
],
"properties": {
"proxyWidgets": [
["27", "text"],
["13", "width"],
["13", "height"],
["28", "unet_name"],
["30", "clip_name"],
["29", "vae_name"],
["3", "steps"],
["3", "control_after_generate"]
],
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
}
],
"links": [[62, 57, 0, 9, 0, "IMAGE"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
"version": 1,
"state": {
"lastGroupId": 4,
"lastNodeId": 61,
"lastLinkId": 70,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Text to Image (Z-Image-Turbo)",
"inputNode": {
"id": -10,
"bounding": [-80, 425, 120, 180]
},
"outputNode": {
"id": -20,
"bounding": [1490, 415, 120, 60]
},
"inputs": [
{
"id": "fb178669-e742-4a53-8a69-7df59834dfd8",
"name": "text",
"type": "STRING",
"linkIds": [34],
"label": "prompt",
"pos": [20, 445]
},
{
"id": "dd780b3c-23e9-46ff-8469-156008f42e5a",
"name": "width",
"type": "INT",
"linkIds": [35],
"pos": [20, 465]
},
{
"id": "7b08d546-6bb0-4ef9-82e9-ffae5e1ee6bc",
"name": "height",
"type": "INT",
"linkIds": [36],
"pos": [20, 485]
},
{
"id": "8ed4eb73-a2bf-4766-8bf4-c5890b560596",
"name": "unet_name",
"type": "COMBO",
"linkIds": [38],
"pos": [20, 505]
},
{
"id": "f362d639-d412-4b5d-8490-1e9995dc5f82",
"name": "clip_name",
"type": "COMBO",
"linkIds": [39],
"pos": [20, 525]
},
{
"id": "ee25ac16-de63-4b74-bbbb-5b29fdc1efcf",
"name": "vae_name",
"type": "COMBO",
"linkIds": [40],
"pos": [20, 545]
},
{
"id": "51cbcd61-9218-4bcb-89ac-ecdfb1ef8892",
"name": "steps",
"type": "INT",
"linkIds": [70],
"pos": [20, 565]
}
],
"outputs": [
{
"id": "1fa72a21-ce00-4952-814e-1f2ffbe87d1d",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [16],
"localized_name": "IMAGE",
"pos": [1510, 435]
}
],
"widgets": [],
"nodes": [
{
"id": 30,
"type": "CLIPLoader",
"pos": [110, 330],
"size": [270, 106],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "clip_name",
"name": "clip_name",
"type": "COMBO",
"widget": {
"name": "clip_name"
},
"link": 39
}
],
"outputs": [
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [28]
}
],
"properties": {
"Node name for S&R": "CLIPLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "qwen_3_4b.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
"directory": "text_encoders"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["qwen_3_4b.safetensors", "lumina2", "default"]
},
{
"id": 29,
"type": "VAELoader",
"pos": [110, 480],
"size": [270, 58],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": 40
}
],
"outputs": [
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [27]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "ae.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
"directory": "vae"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["ae.safetensors"]
},
{
"id": 33,
"type": "ConditioningZeroOut",
"pos": [640, 620],
"size": [204.134765625, 26],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "conditioning",
"name": "conditioning",
"type": "CONDITIONING",
"link": 32
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [33]
}
],
"properties": {
"Node name for S&R": "ConditioningZeroOut",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1220, 160],
"size": [210, 46],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 27
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [16]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 28,
"type": "UNETLoader",
"pos": [110, 200],
"size": [270, 82],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "unet_name",
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 38
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"links": [26]
}
],
"properties": {
"Node name for S&R": "UNETLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "z_image_turbo_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
"directory": "diffusion_models"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["z_image_turbo_bf16.safetensors", "default"]
},
{
"id": 27,
"type": "CLIPTextEncode",
"pos": [430, 200],
"size": [410, 370],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 28
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 34
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [30, 32]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"Latina female with thick wavy hair, harbor boats and pastel houses behind. Breezy seaside light, warm tones, cinematic close-up. "
]
},
{
"id": 13,
"type": "EmptySD3LatentImage",
"pos": [110, 630],
"size": [260, 110],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": 35
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": 36
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [17]
}
],
"properties": {
"Node name for S&R": "EmptySD3LatentImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [1024, 1024, 1]
},
{
"id": 11,
"type": "ModelSamplingAuraFlow",
"pos": [880, 160],
"size": [310, 60],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 26
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "ModelSamplingAuraFlow",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [3]
},
{
"id": 3,
"type": "KSampler",
"pos": [880, 270],
"size": [315, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 13
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 30
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 33
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 17
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": 70
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [14]
}
],
"properties": {
"Node name for S&R": "KSampler",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
0,
"randomize",
8,
1,
"res_multistep",
"simple",
1
]
}
],
"groups": [
{
"id": 2,
"title": "Step2 - Image size",
"bounding": [100, 560, 290, 200],
"color": "#3f789e",
"flags": {}
},
{
"id": 3,
"title": "Step3 - Prompt",
"bounding": [410, 130, 450, 540],
"color": "#3f789e",
"flags": {}
},
{
"id": 4,
"title": "Step1 - Load models",
"bounding": [100, 130, 290, 413.6],
"color": "#3f789e",
"flags": {}
}
],
"links": [
{
"id": 32,
"origin_id": 27,
"origin_slot": 0,
"target_id": 33,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 26,
"origin_id": 28,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 14,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 27,
"origin_id": 29,
"origin_slot": 0,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 13,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 30,
"origin_id": 27,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 33,
"origin_id": 33,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 17,
"origin_id": 13,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 28,
"origin_id": 30,
"origin_slot": 0,
"target_id": 27,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 16,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 34,
"origin_id": -10,
"origin_slot": 0,
"target_id": 27,
"target_slot": 1,
"type": "STRING"
},
{
"id": 35,
"origin_id": -10,
"origin_slot": 1,
"target_id": 13,
"target_slot": 0,
"type": "INT"
},
{
"id": 36,
"origin_id": -10,
"origin_slot": 2,
"target_id": 13,
"target_slot": 1,
"type": "INT"
},
{
"id": 38,
"origin_id": -10,
"origin_slot": 3,
"target_id": 28,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 39,
"origin_id": -10,
"origin_slot": 4,
"target_id": 30,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 40,
"origin_id": -10,
"origin_slot": 5,
"target_id": 29,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 70,
"origin_id": -10,
"origin_slot": 6,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "LG"
}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6488294314381271,
"offset": [733, 392.7886597938144]
},
"frontendVersion": "1.43.4",
"workflowRendererVersion": "LG",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}

View File

@@ -1,555 +0,0 @@
{
"id": "b04d981f-6857-48cc-ac6e-429ab2f6bc8d",
"revision": 0,
"last_node_id": 11,
"last_link_id": 16,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": [1451.0058559453123, 189.0019842294924],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 13
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [25.988896564209426, 473.9973077158204],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [11]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [10]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"pos": [711.776576770508, 420.55569028417983],
"size": [400, 293],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 10
},
{
"name": "model",
"type": "MODEL",
"link": 11
},
{
"name": "vae",
"type": "VAE",
"link": 12
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [13]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"],
["3", "seed"]
]
},
"widgets_values": []
}
],
"links": [
[10, 4, 1, 10, 0, "CLIP"],
[11, 4, 0, 10, 1, "MODEL"],
[12, 4, 2, 10, 2, "VAE"],
[13, 10, 0, 9, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233, 404.5, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1494, 424.5, 120, 60]
},
"inputs": [
{
"id": "85b7a46b-f14c-4297-8b86-3fc73a41da2b",
"name": "clip",
"type": "CLIP",
"linkIds": [14],
"localized_name": "clip",
"pos": [333, 424.5]
},
{
"id": "b4040cb7-0457-416e-ad6e-14890b871dd2",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [333, 444.5]
},
{
"id": "e61199fa-9113-4532-a3d9-879095969171",
"name": "vae",
"type": "VAE",
"linkIds": [8],
"localized_name": "vae",
"pos": [333, 464.5]
}
],
"outputs": [
{
"id": "a4705fa5-a5e6-4c4e-83c2-dfb875861466",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [9],
"localized_name": "IMAGE",
"pos": [1514, 444.5]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [473.007643669922, 609.0214689174805],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [2]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 3,
"type": "KSampler",
"pos": [862.990643669922, 185.9853293300783],
"size": [400, 317],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 16
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 15
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1209.0062878349609, 188.00400724755877],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 11,
"type": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"pos": [485.5190761650391, 283.9247189174806],
"size": [400, 237],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 14
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [15]
},
{
"localized_name": "CONDITIONING_1",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"links": [16]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 5,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 8,
"origin_id": -10,
"origin_slot": 2,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 9,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 16,
"origin_id": 11,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
},
{
"id": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233.01228575000005, 332.7902770140076, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [
898.2956109453125, 322.7902770140076, 138.31666564941406, 80
]
},
"inputs": [
{
"id": "e5074a9c-3b33-4998-b569-0638817e81e7",
"name": "clip",
"type": "CLIP",
"linkIds": [5, 3],
"localized_name": "clip",
"pos": [55, 20]
}
],
"outputs": [
{
"id": "5fd778da-7ff1-4a0b-9282-d11a2e332e15",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "CONDITIONING",
"pos": [20, 20]
},
{
"id": "1e02089f-6491-45fa-aa0a-24458100f8ae",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "CONDITIONING_1",
"pos": [20, 40]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [413.01228575000005, 388.98593823266606],
"size": [425, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [6]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [414.99053247091683, 185.9946096918335],
"size": [423, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [4]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
}
],
"groups": [],
"links": [
{
"id": 5,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 3,
"origin_id": -10,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 6,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 4,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6830134553650709,
"offset": [-203.70966200000038, 259.92420099999975]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -5,7 +5,7 @@ import type {
Page
} from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import { config as dotenvConfig } from 'dotenv'
import dotenv from 'dotenv'
import { TestIds } from './selectors'
import { NodeBadgeMode } from '../../src/types/nodeSource'
@@ -18,8 +18,6 @@ import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
import { ConfirmDialog } from './components/ConfirmDialog'
import { QueuePanel } from './components/QueuePanel'
import {
NodeLibrarySidebarTab,
WorkflowsSidebarTab
@@ -40,8 +38,9 @@ import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
import type { NodeReference } from './utils/litegraphUtils'
import type { WorkspaceStore } from '../types/globals'
dotenvConfig()
dotenv.config()
class ComfyPropertiesPanel {
readonly root: Locator
@@ -112,6 +111,48 @@ class ComfyMenu {
}
}
type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
class ConfirmDialog {
private readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
public readonly confirm: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.delete = this.root.getByRole('button', { name: 'Delete' })
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator]
await loc.waitFor({ state: 'visible' })
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() =>
(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
export class ComfyPage {
public readonly url: string
// All canvas position operations are based on default view of canvas.
@@ -150,7 +191,6 @@ export class ComfyPage {
public readonly featureFlags: FeatureFlagHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly queue: QueueHelper
@@ -176,7 +216,7 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
this.searchBoxV2 = new ComfyNodeSearchBoxV2(this)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
@@ -197,7 +237,6 @@ export class ComfyPage {
this.featureFlags = new FeatureFlagHelper(page)
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.queue = new QueueHelper(page)
}
@@ -471,4 +510,4 @@ export const comfyExpect = expect.extend({
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
}
}
})
})

View File

@@ -1,18 +1,25 @@
import type { Locator, Page } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator
readonly filterSearch: Locator
readonly results: Locator
readonly filterOptions: Locator
readonly filterChips: Locator
readonly noResults: Locator
constructor(readonly page: Page) {
constructor(private comfyPage: ComfyPage) {
const page = comfyPage.page
this.dialog = page.getByRole('search')
this.input = this.dialog.locator('input[type="text"]')
this.input = this.dialog.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
this.filterChips = this.dialog.getByTestId('filter-chip')
this.noResults = this.dialog.getByTestId('no-results')
}
categoryButton(categoryId: string): Locator {
@@ -23,7 +30,37 @@ export class ComfyNodeSearchBoxV2 {
return this.dialog.getByRole('button', { name })
}
async reload(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
async applyTypeFilter(
filterName: 'Input' | 'Output',
typeName: string
): Promise<void> {
await this.filterBarButton(filterName).click()
await this.filterOptions.first().waitFor({ state: 'visible' })
await this.filterSearch.fill(typeName)
await this.filterOptions.filter({ hasText: typeName }).first().click()
// Close the popover by clicking the trigger button again
await this.filterBarButton(filterName).click()
await this.filterOptions.first().waitFor({ state: 'hidden' })
}
async removeFilterChip(index: number = 0): Promise<void> {
await this.filterChips.nth(index).getByTestId('chip-delete').click()
}
async getResultCount(): Promise<number> {
await this.results.first().waitFor({ state: 'visible' })
return this.results.count()
}
async open(): Promise<void> {
await this.comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
await this.input.waitFor({ state: 'visible' })
}
async enableV2Search(): Promise<void> {
await this.comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
}
}

View File

@@ -1,64 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
/**
* Page object for the generic confirm dialog shown via `dialogService.confirm()`.
*
* Accessible on `comfyPage.confirmDialog`.
*/
export class ConfirmDialog {
readonly root: Locator
readonly delete: Locator
readonly overwrite: Locator
/** Cancel / reject button */
readonly reject: Locator
/** Primary confirm button */
readonly confirm: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.delete = this.root.getByRole('button', { name: 'Delete' })
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
}
async isVisible(): Promise<boolean> {
return this.root.isVisible()
}
async waitForVisible(): Promise<void> {
await this.root.waitFor({ state: 'visible' })
}
async waitForHidden(): Promise<void> {
await this.root.waitFor({ state: 'hidden' })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator]
await loc.waitFor({ state: 'visible' })
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() =>
(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}

View File

@@ -1,56 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { comfyExpect as expect } from '../ComfyPage'
import { TestIds } from '../selectors'
/**
* Page object for the "Clear queue history?" confirmation dialog that opens
* from the queue panel's history actions menu.
*/
export class QueueClearHistoryDialog {
readonly root: Locator
readonly cancelButton: Locator
readonly clearButton: Locator
readonly closeButton: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.cancelButton = this.root.getByRole('button', { name: 'Cancel' })
this.clearButton = this.root.getByRole('button', { name: 'Clear' })
this.closeButton = this.root.getByLabel('Close')
}
async isVisible(): Promise<boolean> {
return this.root.isVisible()
}
async waitForVisible(): Promise<void> {
await this.root.waitFor({ state: 'visible' })
}
async waitForHidden(): Promise<void> {
await this.root.waitFor({ state: 'hidden' })
}
}
export class QueuePanel {
readonly overlayToggle: Locator
readonly moreOptionsButton: Locator
readonly clearHistoryDialog: QueueClearHistoryDialog
constructor(readonly page: Page) {
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
this.moreOptionsButton = page.getByLabel(/More options/i).first()
this.clearHistoryDialog = new QueueClearHistoryDialog(page)
}
async openClearHistoryDialog() {
await this.moreOptionsButton.click()
const clearHistoryAction = this.page.getByTestId(
TestIds.queue.clearHistoryAction
)
await expect(clearHistoryAction).toBeVisible()
await clearHistoryAction.click()
}
}

View File

@@ -91,12 +91,6 @@ export class CanvasHelper {
await this.page.mouse.move(10, 10)
}
async isReadOnly(): Promise<boolean> {
return this.page.evaluate(() => {
return window.app!.canvas.state.readOnly
})
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale

View File

@@ -25,15 +25,13 @@ export class DragDropHelper {
url?: string
dropPosition?: Position
waitForUpload?: boolean
preserveNativePropagation?: boolean
} = {}
): Promise<void> {
const {
dropPosition = { x: 100, y: 100 },
fileName,
url,
waitForUpload = false,
preserveNativePropagation = false
waitForUpload = false
} = options
if (!fileName && !url)
@@ -45,8 +43,7 @@ export class DragDropHelper {
fileType?: string
buffer?: Uint8Array | number[]
url?: string
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
} = { dropPosition }
if (fileName) {
const filePath = this.assetPath(fileName)
@@ -118,17 +115,15 @@ export class DragDropHelper {
)
}
if (!params.preserveNativePropagation) {
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
}
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
@@ -159,10 +154,7 @@ export class DragDropHelper {
async dragAndDropURL(
url: string,
options: {
dropPosition?: Position
preserveNativePropagation?: boolean
} = {}
options: { dropPosition?: Position } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ url, ...options })
}

View File

@@ -1,6 +1,7 @@
import type { Locator } from '@playwright/test'
import type {
GraphAddOptions,
LGraph,
LGraphNode
} from '../../../src/lib/litegraph/src/litegraph'
@@ -23,6 +24,12 @@ export class NodeOperationsHelper {
})
}
async getLinkCount(): Promise<number> {
return await this.page.evaluate(() => {
return window.app?.rootGraph?.links?.size ?? 0
})
}
async getSelectedGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return (
@@ -33,6 +40,45 @@ export class NodeOperationsHelper {
})
}
async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
})
}
/**
* Add a node to the graph by type.
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
* true and cursorPosition is provided, a synthetic MouseEvent is created
* as the dragEvent.
* @param cursorPosition - Client coordinates for ghost placement dragEvent
*/
async addNode(
type: string,
options?: Omit<GraphAddOptions, 'dragEvent'>,
cursorPosition?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, cursor]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && cursor) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: cursor.x,
clientY: cursor.y
})
}
window.app!.graph.add(node, addOpts as GraphAddOptions)
return node.id
},
[type, options ?? {}, cursorPosition ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)

View File

@@ -23,7 +23,6 @@ export interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
heapUsedBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
@@ -191,7 +190,6 @@ export class PerformanceHelper {
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize'),
heapUsedBytes: after.JSHeapUsedSize,
domNodes: delta('Nodes'),
jsHeapTotalBytes: delta('JSHeapTotalSize'),
scriptDurationMs: delta('ScriptDuration') * 1000,

View File

@@ -62,8 +62,6 @@ export const TestIds = {
colorRed: 'red'
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
@@ -84,10 +82,6 @@ export const TestIds = {
user: {
currentUserIndicator: 'current-user-indicator'
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
@@ -116,5 +110,4 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -1,10 +1,11 @@
import { config as dotenvConfig } from 'dotenv'
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { backupPath } from './utils/backupUtils'
dotenvConfig()
dotenv.config()
export default function globalSetup() {
export default function globalSetup(_config: FullConfig) {
if (!process.env.CI) {
if (process.env.TEST_COMFYUI_DIR) {
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])

View File

@@ -1,11 +1,12 @@
import { config as dotenvConfig } from 'dotenv'
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { writePerfReport } from './helpers/perfReporter'
import { restorePath } from './utils/backupUtils'
dotenvConfig()
dotenv.config()
export default function globalTeardown() {
export default function globalTeardown(_config: FullConfig) {
writePerfReport()
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {

View File

@@ -38,13 +38,16 @@ const customColorPalettes = {
CLEAR_BACKGROUND_COLOR: '#222222',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',
@@ -99,13 +102,16 @@ const customColorPalettes = {
CLEAR_BACKGROUND_COLOR: '#000',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',

View File

@@ -18,13 +18,15 @@ test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
.catch(() => {})
}, longFilename)
const dialog = comfyPage.confirmDialog
await dialog.waitForVisible()
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.confirm).toBeVisible()
await expect(dialog.confirm).toBeInViewport()
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })
await expect(confirmButton).toBeVisible()
await expect(confirmButton).toBeInViewport()
await expect(dialog.reject).toBeVisible()
await expect(dialog.reject).toBeInViewport()
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await expect(cancelButton).toBeVisible()
await expect(cancelButton).toBeInViewport()
})
})
})

View File

@@ -1,318 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function pressKeyAndExpectRequest(
comfyPage: ComfyPage,
key: string,
urlPattern: string,
method: string = 'POST'
) {
const requestPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes(urlPattern) && req.method() === method,
{ timeout: 5000 }
)
await comfyPage.page.keyboard.press(key)
return requestPromise
}
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test.describe('Sidebar Toggle Shortcuts', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
})
const sidebarTabs = [
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
] as const
for (const { key, tabId, label } of sidebarTabs) {
test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => {
const selectedButton = comfyPage.page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
)
await expect(selectedButton).not.toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).not.toBeVisible()
})
}
})
test.describe('Canvas View Controls', () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeGreaterThan(initialScale)
})
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
})
test("'.' fits view to nodes", async ({ comfyPage }) => {
// Set scale very small so fit-view will zoom back to fit nodes
await comfyPage.canvasOps.setScale(0.1)
const scaleBefore = await comfyPage.canvasOps.getScale()
expect(scaleBefore).toBeCloseTo(0.1, 1)
// Click canvas to ensure focus is within graph-canvas-container
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
const scaleAfter = await comfyPage.canvasOps.getScale()
expect(scaleAfter).toBeGreaterThan(scaleBefore)
})
test("'h' locks canvas", async ({ comfyPage }) => {
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
})
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
})
})
test.describe('Node State Toggles', () => {
test("'Alt+c' collapses and expands selected nodes", async ({
comfyPage
}) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
})
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
expect(await getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
// NEVER (2) = muted
expect(await getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
expect(await getMode()).toBe(0)
})
})
test.describe('Mode and Panel Toggles', () => {
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Set up linearData so app mode has something to show
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
// Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
// Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).toBeVisible()
})
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
})
})
test.describe('Queue and Execution', () => {
test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Enter',
'/prompt',
'POST'
)
expect(request.url()).toContain('/prompt')
})
test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Shift+Enter',
'/prompt',
'POST'
)
const body = request.postDataJSON()
expect(body.front).toBe(true)
})
test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Alt+Enter',
'/interrupt',
'POST'
)
expect(request.url()).toContain('/interrupt')
})
})
test.describe('File Operations', () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
// Dismiss the dialog
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
// Ctrl+o calls app.ui.loadFile() which clicks a hidden file input.
// Detect the file input click via an event listener.
await comfyPage.page.evaluate(() => {
window.TestCommand = false
const fileInputs =
document.querySelectorAll<HTMLInputElement>('input[type="file"]')
for (const input of fileInputs) {
input.addEventListener('click', () => {
window.TestCommand = true
})
}
})
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
})
})
test.describe('Graph Operations', () => {
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
// Select all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5000
})
.toBeLessThan(initialCount)
})
test("'r' refreshes node definitions", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'KeyR',
'/object_info',
'GET'
)
expect(request.url()).toContain('/object_info')
})
})
})

View File

@@ -1,137 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('QueueClearHistoryDialog', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
// Expand the queue overlay so the JobHistoryActionsMenu is visible
await comfyPage.queuePanel.overlayToggle.click()
})
test('Dialog opens from queue panel history actions menu', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
await expect(comfyPage.queuePanel.clearHistoryDialog.root).toBeVisible()
})
test('Dialog shows confirmation message with title, description, and assets note', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.queuePanel.clearHistoryDialog
await expect(dialog.root).toBeVisible()
// Verify title
await expect(
dialog.root.getByText('Clear your job queue history?')
).toBeVisible()
// Verify description
await expect(
dialog.root.getByText(
'All the finished or failed jobs below will be removed from this Job queue panel.'
)
).toBeVisible()
// Verify assets note (locale uses Unicode RIGHT SINGLE QUOTATION MARK \u2019)
await expect(
dialog.root.getByText(
'Assets generated by these jobs won\u2019t be deleted and can always be viewed from the assets panel.'
)
).toBeVisible()
})
test('Cancel button closes dialog without clearing history', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.queuePanel.clearHistoryDialog
await expect(dialog.root).toBeVisible()
// Intercept the clear API call — it should NOT be called
let clearCalled = false
await comfyPage.page.route('**/api/history', (route) => {
if (route.request().method() === 'POST') {
clearCalled = true
}
return route.continue()
})
await dialog.cancelButton.click()
await expect(dialog.root).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
})
test('Close (X) button closes dialog without clearing history', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.queuePanel.clearHistoryDialog
await expect(dialog.root).toBeVisible()
// Intercept the clear API call — it should NOT be called
let clearCalled = false
await comfyPage.page.route('**/api/history', (route) => {
if (route.request().method() === 'POST') {
clearCalled = true
}
return route.continue()
})
await dialog.closeButton.click()
await expect(dialog.root).not.toBeVisible()
expect(clearCalled).toBe(false)
await comfyPage.page.unroute('**/api/history')
})
test('Confirm clears queue history and closes dialog', async ({
comfyPage
}) => {
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.queuePanel.clearHistoryDialog
await expect(dialog.root).toBeVisible()
// Intercept the clear API call to verify it is made
const clearPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes('/api/history') && req.method() === 'POST'
)
await dialog.clearButton.click()
// Verify the API call was made
const request = await clearPromise
expect(request.postDataJSON()).toEqual({ clear: true })
await expect(dialog.root).not.toBeVisible()
})
test('Dialog state resets after close and reopen', async ({ comfyPage }) => {
// Open and cancel
await comfyPage.queuePanel.openClearHistoryDialog()
const dialog = comfyPage.queuePanel.clearHistoryDialog
await expect(dialog.root).toBeVisible()
await dialog.cancelButton.click()
await expect(dialog.root).not.toBeVisible()
// Reopen — dialog should be fresh (Clear button enabled, not stuck)
await comfyPage.queuePanel.openClearHistoryDialog()
await expect(dialog.root).toBeVisible()
await expect(dialog.clearButton).toBeVisible()
await expect(dialog.clearButton).toBeEnabled()
})
})

View File

@@ -10,7 +10,6 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import { TestIds } from '../fixtures/selectors'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import type { WorkspaceStore } from '../types/globals'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -721,19 +720,6 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await expect(comfyPage.canvas).toHaveScreenshot('string_input.png')
})
test('Creates initial workflow tab when persistence is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', false)
await comfyPage.setup()
const openCount = await comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.openWorkflows.length
})
expect(openCount).toBeGreaterThanOrEqual(1)
})
test('Restore workflow on reload (switch workflow)', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -67,44 +67,5 @@ test.describe(
)
})
})
test.fixme('Load workflow from URL dropped onto Vue node', async ({
comfyPage
}) => {
const fakeUrl = 'https://example.com/workflow.png'
await comfyPage.page.route(fakeUrl, (route) =>
route.fulfill({
path: comfyPage.assetPath('workflowInMedia/workflow_itxt.png')
})
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
const node = comfyPage.vueNodes.getNodeByTitle('KSampler')
const box = await node.boundingBox()
expect(box).not.toBeNull()
const dropPosition = {
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2
}
await comfyPage.dragDrop.dragAndDropURL(fakeUrl, {
dropPosition,
preserveNativePropagation: true
})
await comfyPage.page.waitForFunction(
(prevCount) => window.app!.graph.nodes.length !== prevCount,
initialNodeCount,
{ timeout: 10000 }
)
const newNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newNodeCount).not.toBe(initialNodeCount)
})
}
)

View File

@@ -23,18 +23,14 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
const nodeRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
return { nodeId, centerX, centerY }
return { nodeId: nodeRef.id, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
@@ -82,7 +78,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
@@ -158,5 +153,53 @@ for (const mode of ['litegraph', 'vue'] as const) {
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('moving ghost onto existing node and clicking places correctly', async ({
comfyPage
}) => {
// Get existing KSampler node from the default workflow
const [ksamplerRef] =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
const ksamplerPos = await ksamplerRef.getPosition()
const ksamplerSize = await ksamplerRef.getSize()
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
// Start ghost placement away from the existing node
const startX = 50
const startY = 50
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
await comfyPage.nextFrame()
const ghostRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: startX, y: startY }
)
await comfyPage.nextFrame()
// Move ghost onto the existing node
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
await comfyPage.nextFrame()
// Click to finalize — on top of the existing node
await comfyPage.page.mouse.click(targetX, targetY)
await comfyPage.nextFrame()
// Ghost should be placed (no longer ghost)
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
expect(ghostResult).not.toBeNull()
expect(ghostResult!.ghost).toBe(false)
// Ghost node should have moved from its start position toward where we clicked
const ghostPos = await ghostRef.getPosition()
expect(
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
).toBe(true)
// Existing node should NOT be selected
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
expect(selectedIds).not.toContain(ksamplerRef.id)
})
})
}

View File

@@ -5,8 +5,7 @@ import {
test.describe('Node search box V2', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.searchBoxV2.enableV2Search()
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
@@ -15,15 +14,13 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
})
test('Can open search and add node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -39,8 +36,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
// Default results should be visible without typing
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -54,17 +50,16 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
})
test.describe('Category navigation', () => {
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
test('Bookmarked filter shows only bookmarked nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'KSampler'
])
await searchBoxV2.reload(comfyPage)
await searchBoxV2.open()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('favorites').click()
await searchBoxV2.filterBarButton('Bookmarked').click()
await expect(searchBoxV2.results).toHaveCount(1)
await expect(searchBoxV2.results.first()).toContainText('KSampler')
@@ -75,8 +70,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
@@ -90,8 +84,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
test('Can filter by input type via filter bar', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
// Click "Input" filter chip in the filter bar
await searchBoxV2.filterBarButton('Input').click()
@@ -100,7 +93,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
// Type to narrow and select MODEL
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
@@ -119,8 +112,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results

View File

@@ -5,8 +5,7 @@ import {
test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.searchBoxV2.enableV2Search()
await comfyPage.settings.setSetting(
'Comfy.LinkRelease.Action',
'search box'
@@ -15,13 +14,12 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
'Comfy.LinkRelease.ActionShift',
'search box'
)
await comfyPage.searchBoxV2.reload(comfyPage)
})
test('Double-click on empty canvas opens search', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await expect(searchBoxV2.input).toBeVisible()
await expect(searchBoxV2.dialog).toBeVisible()
})
@@ -32,8 +30,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -45,29 +42,43 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
expect(newCount).toBe(initialCount)
})
test('Search clears when reopening', async ({ comfyPage }) => {
test('Reopening search after Enter has no persisted state', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).not.toBeVisible()
await searchBoxV2.open()
await expect(searchBoxV2.input).toHaveValue('')
await expect(searchBoxV2.filterChips).toHaveCount(0)
})
test('Reopening search after Escape has no persisted state', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(searchBoxV2.input).not.toBeVisible()
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await expect(searchBoxV2.input).toHaveValue('')
await expect(searchBoxV2.filterChips).toHaveCount(0)
})
test.describe('Category navigation', () => {
test('Category navigation updates results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.categoryButton('sampling').click()
await expect(searchBoxV2.results.first()).toBeVisible()
@@ -85,59 +96,270 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test('Filter chip removal restores results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.open()
// Record initial result text for comparison
await expect(searchBoxV2.results.first()).toBeVisible()
const unfilteredResults = await searchBoxV2.results.allTextContents()
// Search first to get a result set below the 64-item cap
await searchBoxV2.input.fill('Load')
const unfilteredCount = await searchBoxV2.getResultCount()
// Apply Input filter with MODEL type
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
.click()
// Verify filter chip appeared and results changed
const filterChip = searchBoxV2.dialog.locator(
'[data-testid="filter-chip"]'
)
await expect(filterChip).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
const filteredResults = await searchBoxV2.results.allTextContents()
expect(filteredResults).not.toEqual(unfilteredResults)
await searchBoxV2.applyTypeFilter('Input', 'MODEL')
await expect(searchBoxV2.filterChips.first()).toBeVisible()
const filteredCount = await searchBoxV2.getResultCount()
expect(filteredCount).not.toBe(unfilteredCount)
// Remove filter by clicking the chip delete button
await filterChip.getByTestId('chip-delete').click()
await searchBoxV2.removeFilterChip()
// Filter chip should be removed
await expect(filterChip).not.toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
// Filter chip should be removed and count restored
await expect(searchBoxV2.filterChips).toHaveCount(0)
const restoredCount = await searchBoxV2.getResultCount()
expect(restoredCount).toBe(unfilteredCount)
})
})
test.describe('Keyboard navigation', () => {
test('ArrowUp on first item keeps first selected', async ({
test.describe('Link release', () => {
test('Link release opens search with pre-applied type filter', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.canvasOps.doubleClick()
await comfyPage.canvasOps.disconnectEdge()
await expect(searchBoxV2.input).toBeVisible()
// disconnectEdge pulls a CLIP link - should have a filter chip
await expect(searchBoxV2.filterChips).toHaveCount(1)
await expect(searchBoxV2.filterChips.first()).toContainText('CLIP')
})
test('Link release auto-connects added node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
const linkCountBefore = await comfyPage.nodeOps.getLinkCount()
await comfyPage.canvasOps.disconnectEdge()
await expect(searchBoxV2.input).toBeVisible()
// Search for a node that accepts CLIP input and select it
await searchBoxV2.input.fill('CLIP Text Encode')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).not.toBeVisible()
// A new node should have been added and auto-connected
const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount()
expect(nodeCountAfter).toBe(nodeCountBefore + 1)
const linkCountAfter = await comfyPage.nodeOps.getLinkCount()
expect(linkCountAfter).toBe(linkCountBefore)
})
})
test.describe('Filter combinations', () => {
test('Output type filter filters results', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Search first so both counts use the search service path
await searchBoxV2.input.fill('Load')
const unfilteredCount = await searchBoxV2.getResultCount()
await searchBoxV2.applyTypeFilter('Output', 'IMAGE')
await expect(searchBoxV2.filterChips).toHaveCount(1)
const filteredCount = await searchBoxV2.getResultCount()
expect(filteredCount).not.toBe(unfilteredCount)
})
test('Multiple type filters (Input + Output) narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('Input', 'MODEL')
await expect(searchBoxV2.filterChips).toHaveCount(1)
const singleFilterCount = await searchBoxV2.getResultCount()
await searchBoxV2.applyTypeFilter('Output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
const dualFilterCount = await searchBoxV2.getResultCount()
expect(dualFilterCount).toBeLessThan(singleFilterCount)
})
test('Root filter + search query narrows results', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Search without root filter
await searchBoxV2.input.fill('Sampler')
const unfilteredCount = await searchBoxV2.getResultCount()
// Apply Comfy root filter on top of search
await searchBoxV2.filterBarButton('Comfy').click()
const filteredCount = await searchBoxV2.getResultCount()
// Root filter should narrow or maintain the result set
expect(filteredCount).toBeLessThan(unfilteredCount)
expect(filteredCount).toBeGreaterThan(0)
})
test('Root filter + category selection', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Click "Comfy" root filter
await searchBoxV2.filterBarButton('Comfy').click()
const comfyCount = await searchBoxV2.getResultCount()
// Under root filter, categories are prefixed (e.g. comfy/sampling)
await searchBoxV2.categoryButton('comfy/sampling').click()
const comfySamplingCount = await searchBoxV2.getResultCount()
expect(comfySamplingCount).toBeLessThan(comfyCount)
})
})
test.describe('Category sidebar', () => {
test('Category tree expand and collapse', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Click a parent category to expand it
const samplingBtn = searchBoxV2.categoryButton('sampling')
await samplingBtn.click()
// Look for subcategories (e.g. sampling/custom_sampling)
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await expect(subcategory).toBeVisible()
// Click sampling again to collapse
await samplingBtn.click()
await expect(subcategory).not.toBeVisible()
})
test('Subcategory narrows results to subset', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
// Select parent category
await searchBoxV2.categoryButton('sampling').click()
const parentCount = await searchBoxV2.getResultCount()
// Select subcategory
const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling')
await expect(subcategory).toBeVisible()
await subcategory.click()
const childCount = await searchBoxV2.getResultCount()
expect(childCount).toBeLessThan(parentCount)
})
test('Most relevant resets category filter', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
const defaultCount = await searchBoxV2.getResultCount()
// Select a category
await searchBoxV2.categoryButton('sampling').click()
const samplingCount = await searchBoxV2.getResultCount()
expect(samplingCount).not.toBe(defaultCount)
// Click "Most relevant" to reset
await searchBoxV2.categoryButton('most-relevant').click()
const resetCount = await searchBoxV2.getResultCount()
expect(resetCount).toBe(defaultCount)
})
})
test.describe('Search behavior', () => {
test('Click on result item adds node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
const results = searchBoxV2.results
await expect(results.first()).toBeVisible()
await expect(searchBoxV2.results.first()).toBeVisible()
// First result should be selected by default
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
await searchBoxV2.results.first().click()
await expect(searchBoxV2.input).not.toBeVisible()
// ArrowUp on first item should keep first selected
await comfyPage.page.keyboard.press('ArrowUp')
await expect(results.first()).toHaveAttribute('aria-selected', 'true')
const newCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newCount).toBe(initialCount + 1)
})
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('S')
const count1 = await searchBoxV2.getResultCount()
await searchBoxV2.input.fill('Sa')
const count2 = await searchBoxV2.getResultCount()
await searchBoxV2.input.fill('Sampler')
const count3 = await searchBoxV2.getResultCount()
expect(count2).toBeLessThan(count1)
expect(count3).toBeLessThan(count2)
})
test('No results shown for nonsensical query', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('zzzxxxyyy_nonexistent_node')
await expect(searchBoxV2.noResults).toBeVisible()
await expect(searchBoxV2.results).toHaveCount(0)
})
})
test.describe('Filter chip interaction', () => {
test('Multiple filter chips displayed', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.applyTypeFilter('Input', 'MODEL')
await searchBoxV2.applyTypeFilter('Output', 'LATENT')
await expect(searchBoxV2.filterChips).toHaveCount(2)
await expect(searchBoxV2.filterChips.first()).toContainText('MODEL')
await expect(searchBoxV2.filterChips.nth(1)).toContainText('LATENT')
})
})
test.describe('Settings-driven behavior', () => {
test('Node ID name shown when setting enabled', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.ShowIdName',
true
)
const { searchBoxV2 } = comfyPage
await searchBoxV2.open()
await searchBoxV2.input.fill('VAE Decode')
await expect(searchBoxV2.results.first()).toBeVisible()
const firstResult = searchBoxV2.results.first()
const idBadge = firstResult.getByTestId('node-id-badge')
await expect(idBadge).toBeVisible()
await expect(idBadge).toContainText('VAEDecode')
})
})
})

View File

@@ -154,38 +154,6 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
)
})
test('large graph zoom interaction', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
// Position mouse at center so wheel events hit the canvas
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.perf.startMeasuring()
// Zoom in 30 steps then out 30 steps — each step triggers
// ResizeObserver for all ~245 node elements due to CSS scale change.
for (let i = 0; i < 30; i++) {
await comfyPage.page.mouse.wheel(0, -100)
await comfyPage.nextFrame()
}
for (let i = 0; i < 30; i++) {
await comfyPage.page.mouse.wheel(0, 100)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('large-graph-zoom')
recordMeasurement(m)
console.log(
`Large graph zoom: ${m.layouts} layouts, ${m.layoutDurationMs.toFixed(1)}ms layout, ${m.frameDurationMs.toFixed(1)}ms/frame, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -142,12 +142,12 @@ test.describe(
})
})
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -182,8 +182,8 @@ test.describe(
})
})
.toEqual({
proxyWidgetCount: 0,
firstWidgetType: undefined
proxyWidgetCount: initialWidgets.length,
firstWidgetType: 'button'
})
})

View File

@@ -1,195 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/**
* Regression test for PR #10532:
* Packing all nodes inside a subgraph into a nested subgraph was causing
* the parent subgraph node's promoted widget values to go blank.
*
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
* at promotion time). After repointing, input._widget still pointed to
* removed node IDs, causing missing-node failures and blank values on the
* next checkState cycle.
*/
test.describe(
'Nested subgraph pack preserves promoted widget values',
{ tag: ['@subgraph', '@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
const HOST_NODE_ID = '57'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeLocator).toBeVisible()
// 1. Verify initial promoted widget values via Vue node DOM
const widthWidget = nodeLocator
.getByLabel('width', { exact: true })
.first()
const heightWidget = nodeLocator
.getByLabel('height', { exact: true })
.first()
const stepsWidget = nodeLocator
.getByLabel('steps', { exact: true })
.first()
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
const widthControls =
comfyPage.vueNodes.getInputNumberControls(widthWidget)
const heightControls =
comfyPage.vueNodes.getInputNumberControls(heightWidget)
const stepsControls =
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
await expect(async () => {
await expect(widthControls.input).toHaveValue('1024')
await expect(heightControls.input).toHaveValue('1024')
await expect(stepsControls.input).toHaveValue('8')
await expect(textWidget).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
// 2. Enter the subgraph via Vue node button
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
// 3. Disable Vue nodes for canvas operations (select all + convert)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
// 4. Select all interior nodes and convert to nested subgraph
await comfyPage.canvas.click()
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await comfyPage.nextFrame()
// 5. Navigate back to root graph and trigger a checkState cycle
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
// 6. Re-enable Vue nodes and verify values are preserved
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeAfter).toBeVisible()
const widthAfter = nodeAfter.getByLabel('width', { exact: true }).first()
const heightAfter = nodeAfter
.getByLabel('height', { exact: true })
.first()
const stepsAfter = nodeAfter.getByLabel('steps', { exact: true }).first()
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
const widthControlsAfter =
comfyPage.vueNodes.getInputNumberControls(widthAfter)
const heightControlsAfter =
comfyPage.vueNodes.getInputNumberControls(heightAfter)
const stepsControlsAfter =
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
await expect(async () => {
await expect(widthControlsAfter.input).toHaveValue('1024')
await expect(heightControlsAfter.input).toHaveValue('1024')
await expect(stepsControlsAfter.input).toHaveValue('8')
await expect(textAfter).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
})
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Verify the host node is visible
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeLocator).toBeVisible()
// Enter the subgraph via Vue node button, then disable for canvas ops
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.canvas.click()
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
// Verify all proxyWidgets entries resolve
await expect(async () => {
const result = await comfyPage.page.evaluate((hostId) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(hostId)
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
) {
return { error: 'Host node not found or not a subgraph node' }
}
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
const entries = (proxyWidgets as unknown[])
.filter(
(e): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string' &&
!e[1].startsWith('$$')
)
.map(([nodeId, widgetName]) => {
const interiorNode = hostNode.subgraph.getNodeById(Number(nodeId))
return {
nodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return { entries, count: entries.length }
}, HOST_NODE_ID)
expect(result).not.toHaveProperty('error')
const { entries, count } = result as {
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
count: number
}
expect(count).toBeGreaterThan(0)
for (const entry of entries) {
expect(
entry.resolved,
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
).toBe(true)
}
}).toPass({ timeout: 5000 })
})
}
)

View File

@@ -1,51 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
/**
* Regression test for nested subgraph packing leaving stale proxyWidgets
* on the outer SubgraphNode.
*
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
* Only ["3","seed"] (KSampler) should remain.
*
* Stale entries render as "Disconnected" placeholder widgets (type "button").
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
*/
test.describe(
'Nested subgraph stale proxyWidgets',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(outerNode).toBeVisible()
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
// Only the KSampler seed widget should be present — no stale
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
await expect(widgets).toHaveCount(1)
await expect(widgets.first()).toBeVisible()
// Verify the seed widget is present via its label
const seedWidget = outerNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
})
}
)

View File

@@ -1,132 +0,0 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface SlotMeasurement {
key: string
offsetX: number
offsetY: number
}
interface NodeSlotData {
nodeId: string
isSubgraph: boolean
nodeW: number
nodeH: number
slots: SlotMeasurement[]
}
/**
* Regression test for link misalignment on SubgraphNodes when loading
* workflows with workflowRendererVersion: "LG".
*
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
* slot offsets. The fix uses DOM-relative measurement instead.
*/
test.describe(
'Subgraph slot alignment after LG layout scale',
{ tag: ['@subgraph', '@canvas'] },
() => {
test('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
const SLOT_BOUNDS_MARGIN = 20
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const workflowPath = resolve(
import.meta.dirname,
'../assets/subgraphs/basic-subgraph.json'
)
const workflow = JSON.parse(
readFileSync(workflowPath, 'utf-8')
) as ComfyWorkflowJSON
workflow.extra = {
...workflow.extra,
workflowRendererVersion: 'LG'
}
await comfyPage.page.evaluate(
(wf) =>
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
openSource: 'template'
}),
workflow
)
await comfyPage.nextFrame()
// Wait for slot elements to appear in DOM
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph._nodes
const slotData: NodeSlotData[] = []
for (const node of nodes) {
const nodeId = String(node.id)
const nodeEl = document.querySelector(
`[data-node-id="${nodeId}"]`
) as HTMLElement | null
if (!nodeEl) continue
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
if (slotEls.length === 0) continue
const slots: SlotMeasurement[] = []
const nodeRect = nodeEl.getBoundingClientRect()
for (const slotEl of slotEls) {
const slotRect = slotEl.getBoundingClientRect()
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
slots.push({
key: slotKey,
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
})
}
slotData.push({
nodeId,
isSubgraph: !!node.isSubgraphNode?.(),
nodeW: nodeRect.width,
nodeH: nodeRect.height,
slots
})
}
return slotData
})
const subgraphNodes = result.filter((n) => n.isSubgraph)
expect(subgraphNodes.length).toBeGreaterThan(0)
for (const node of subgraphNodes) {
for (const slot of node.slots) {
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
}
}
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -46,16 +46,4 @@ test.describe('Vue Multiline String Widget', () => {
await expect(textarea).toHaveValue('Keep me around')
})
test('should use native context menu when focused', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const vueContextMenu = comfyPage.page.locator('.p-contextmenu')
await textarea.focus()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).not.toBeVisible()
await textarea.blur()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).toBeVisible()
})
})

View File

@@ -27,17 +27,6 @@ const config: KnipConfig = {
},
'packages/ingest-types': {
project: ['src/**/*.{js,ts}']
},
'apps/website': {
entry: [
'src/pages/**/*.astro',
'src/layouts/**/*.astro',
'src/components/**/*.vue',
'src/styles/global.css',
'astro.config.ts'
],
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
}
},
ignoreBinaries: ['python3'],

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.6",
"version": "1.43.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -44,7 +44,7 @@
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

View File

@@ -1,46 +0,0 @@
/*
* Design System Base — Brand tokens + fonts only.
* For marketing sites that don't use PrimeVue or the node editor.
* Import the full style.css instead for the desktop app.
*/
@import './fonts.css';
@theme {
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
--color-charcoal-500: #2d2e32;
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-ash-300: #bbbbbb;
--color-ash-500: #828282;
--color-ash-800: #444444;
--color-smoke-100: #f3f3f3;
--color-smoke-200: #e9e9e9;
--color-smoke-300: #e1e1e1;
--color-smoke-400: #d9d9d9;
--color-smoke-500: #c5c5c5;
--color-smoke-600: #b4b4b4;
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
--color-white: #ffffff;
--color-black: #000000;
/* Brand Colors */
--color-electric-400: #f0ff41;
--color-sapphire-700: #172dd7;
--color-brand-yellow: var(--color-electric-400);
--color-brand-blue: var(--color-sapphire-700);
}

View File

@@ -619,8 +619,6 @@
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
@utility scrollbar-hide {

View File

@@ -200,6 +200,13 @@ describe('formatUtil', () => {
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
)
})
it('should highlight cross-word matches', () => {
const result = highlightQuery('convert image to mask', 'geto', false)
expect(result).toBe(
'convert ima<span class="highlight">ge to</span> mask'
)
})
})
describe('getFilenameDetails', () => {

View File

@@ -74,10 +74,14 @@ export function highlightQuery(
text = DOMPurify.sanitize(text)
}
// Escape special regex characters in the query string
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Escape special regex characters, then join with optional
// whitespace so cross-word matches (e.g. "geto" → "imaGE TO") are
// highlighted correctly.
const pattern = Array.from(query)
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('\\s*')
const regex = new RegExp(`(${escapedQuery})`, 'gi')
const regex = new RegExp(`(${pattern})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}

View File

@@ -1,9 +1,17 @@
import { clsx } from 'clsx'
import type { ClassArray } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { extendTailwindMerge } from 'tailwind-merge'
export type { ClassValue } from 'clsx'
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': ['text-xxs', 'text-xxxs']
}
}
})
export function cn(...inputs: ClassArray) {
return twMerge(clsx(inputs))
}

1665
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@astrojs/vue': ^5.0.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^9.39.1
'@formkit/auto-animate': ^0.9.0
@@ -51,7 +50,6 @@ catalog:
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
@@ -60,7 +58,6 @@ catalog:
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.13.5
cross-env: ^10.1.0
cva: 1.0.0-beta.4

View File

@@ -22,7 +22,6 @@ interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
heapUsedBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
@@ -44,46 +43,22 @@ const HISTORY_DIR = 'temp/perf-history'
type MetricKey =
| 'styleRecalcs'
| 'styleRecalcDurationMs'
| 'layouts'
| 'layoutDurationMs'
| 'taskDurationMs'
| 'domNodes'
| 'scriptDurationMs'
| 'eventListeners'
| 'totalBlockingTimeMs'
| 'frameDurationMs'
| 'heapUsedBytes'
interface MetricDef {
key: MetricKey
label: string
unit: string
/** Minimum absolute delta to consider meaningful (effect size gate) */
minAbsDelta?: number
}
const REPORTED_METRICS: MetricDef[] = [
{ key: 'layoutDurationMs', label: 'layout duration', unit: 'ms' },
{
key: 'styleRecalcDurationMs',
label: 'style recalc duration',
unit: 'ms'
},
{ key: 'layouts', label: 'layout count', unit: '', minAbsDelta: 5 },
{
key: 'styleRecalcs',
label: 'style recalc count',
unit: '',
minAbsDelta: 5
},
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
{ key: 'layouts', label: 'layouts', unit: '' },
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' },
{ key: 'domNodes', label: 'DOM nodes', unit: '' },
{ key: 'scriptDurationMs', label: 'script duration', unit: 'ms' },
{ key: 'eventListeners', label: 'event listeners', unit: '' },
{ key: 'totalBlockingTimeMs', label: 'TBT', unit: 'ms' },
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' },
{ key: 'heapUsedBytes', label: 'heap used', unit: 'bytes' },
{ key: 'domNodes', label: 'DOM nodes', unit: '', minAbsDelta: 5 },
{ key: 'eventListeners', label: 'event listeners', unit: '', minAbsDelta: 5 }
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' }
]
function groupByName(
@@ -159,9 +134,7 @@ function computeCV(stats: MetricStats): number {
}
function formatValue(value: number, unit: string): string {
if (unit === 'ms') return `${value.toFixed(0)}ms`
if (unit === 'bytes') return formatBytes(value)
return `${value.toFixed(0)}`
return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}`
}
function formatDelta(pct: number | null): string {
@@ -186,21 +159,6 @@ function meanMetric(samples: PerfMeasurement[], key: MetricKey): number | null {
return values.reduce((sum, v) => sum + v, 0) / values.length
}
function medianMetric(
samples: PerfMeasurement[],
key: MetricKey
): number | null {
const values = samples
.map((s) => getMetricValue(s, key))
.filter((v): v is number => v !== null)
.sort((a, b) => a - b)
if (values.length === 0) return null
const mid = Math.floor(values.length / 2)
return values.length % 2 === 0
? (values[mid - 1] + values[mid]) / 2
: values[mid]
}
function formatBytes(bytes: number): string {
if (Math.abs(bytes) < 1024) return `${bytes} B`
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
@@ -215,7 +173,7 @@ function renderFullReport(
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
const tableHeader = [
'| Metric | Baseline | PR (median) | Δ | Sig |',
'| Metric | Baseline | PR (n=3) | Δ | Sig |',
'|--------|----------|----------|---|-----|'
]
@@ -225,38 +183,36 @@ function renderFullReport(
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit, minAbsDelta } of REPORTED_METRICS) {
// Use median for PR values — robust to outlier runs in CI
const prVal = medianMetric(prSamples, key)
if (prVal === null) continue
for (const { key, label, unit } of REPORTED_METRICS) {
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
const histStats = getHistoricalStats(historical, testName, key)
const cv = computeCV(histStats)
if (!baseSamples?.length) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
)
continue
}
const baseVal = medianMetric(baseSamples, key)
const baseVal = meanMetric(baseSamples, key)
if (baseVal === null) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
)
continue
}
const absDelta = prVal - baseVal
const deltaPct =
baseVal === 0
? prVal === 0
? prMean === 0
? 0
: null
: ((prVal - baseVal) / baseVal) * 100
const z = zScore(prVal, histStats)
const sig = classifyChange(z, cv, absDelta, minAbsDelta)
: ((prMean - baseVal) / baseVal) * 100
const z = zScore(prMean, histStats)
const sig = classifyChange(z, cv)
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
allRows.push(row)
if (isNoteworthy(sig)) {
flaggedRows.push(row)
@@ -343,7 +299,7 @@ function renderColdStartReport(
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
lines.push(
`> Collecting baseline variance data (${historicalCount}/15 runs). Significance will appear after 2 main branch runs.`,
`> Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`,
'',
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
@@ -353,31 +309,31 @@ function renderColdStartReport(
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prVal = medianMetric(prSamples, key)
if (prVal === null) continue
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
if (!baseSamples?.length) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
)
continue
}
const baseVal = medianMetric(baseSamples, key)
const baseVal = meanMetric(baseSamples, key)
if (baseVal === null) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
)
continue
}
const deltaPct =
baseVal === 0
? prVal === 0
? prMean === 0
? 0
: null
: ((prVal - baseVal) / baseVal) * 100
: ((prMean - baseVal) / baseVal) * 100
lines.push(
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} |`
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |`
)
}
}
@@ -396,10 +352,14 @@ function renderNoBaselineReport(
)
for (const [testName, prSamples] of prGroups) {
for (const { key, label, unit } of REPORTED_METRICS) {
const prVal = medianMetric(prSamples, key)
if (prVal === null) continue
lines.push(`| ${testName}: ${label} | ${formatValue(prVal, unit)} |`)
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
lines.push(`| ${testName}: ${label} | ${formatValue(prMean, unit)} |`)
}
const heapMean =
prSamples.reduce((sum, s) => sum + (s.heapDeltaBytes ?? 0), 0) /
prSamples.length
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
}
return lines
}

View File

@@ -99,21 +99,6 @@ describe('classifyChange', () => {
expect(classifyChange(2, 10)).toBe('neutral')
expect(classifyChange(-2, 10)).toBe('neutral')
})
it('returns neutral when absDelta below minAbsDelta despite high z', () => {
// z=7.2 but only 1 unit change with minAbsDelta=5
expect(classifyChange(7.2, 10, 1, 5)).toBe('neutral')
expect(classifyChange(-7.2, 10, -1, 5)).toBe('neutral')
})
it('returns regression when absDelta meets minAbsDelta', () => {
expect(classifyChange(3, 10, 10, 5)).toBe('regression')
})
it('ignores effect size gate when minAbsDelta not provided', () => {
expect(classifyChange(3, 10)).toBe('regression')
expect(classifyChange(3, 10, 1)).toBe('regression')
})
})
describe('formatSignificance', () => {

View File

@@ -31,28 +31,12 @@ export function zScore(value: number, stats: MetricStats): number | null {
export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy'
/**
* Classify a metric change as regression/improvement/neutral/noisy.
*
* Uses both statistical significance (z-score) and practical significance
* (effect size gate via minAbsDelta) to reduce false positives from
* integer-quantized metrics with near-zero variance.
*/
export function classifyChange(
z: number | null,
historicalCV: number,
absDelta?: number,
minAbsDelta?: number
historicalCV: number
): Significance {
if (historicalCV > 50) return 'noisy'
if (z === null) return 'neutral'
// Effect size gate: require minimum absolute change for count metrics
// to avoid flagging e.g. 11→12 style recalcs as z=7.2 regression.
if (minAbsDelta !== undefined && absDelta !== undefined) {
if (Math.abs(absDelta) < minAbsDelta) return 'neutral'
}
if (z > 2) return 'regression'
if (z < -2) return 'improvement'
return 'neutral'

View File

@@ -9,10 +9,13 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
@@ -20,6 +23,7 @@ import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -94,17 +98,12 @@ onMounted(() => {
}
})
}
// Disabled: Third-party custom node extensions frequently trigger this toast
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
// the generic error message alarms users and offers no actionable guidance.
// The console.error above still logs the details for developers to debug.
// useToastStore().add({
// severity: 'error',
// summary: t('g.preloadErrorTitle'),
// detail: t('g.preloadError'),
// life: 10000
// })
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions

View File

@@ -34,7 +34,9 @@
"CLEAR_BACKGROUND_COLOR": "#2b2f38",
"NODE_TITLE_COLOR": "#b2b7bd",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2b2f38",
"NODE_DEFAULT_BGCOLOR": "#242730",
"NODE_DEFAULT_BOXCOLOR": "#6e7581",
@@ -43,6 +45,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 22,
"WIDGET_BGCOLOR": "#2b2f38",
"WIDGET_OUTLINE_COLOR": "#6e7581",
"WIDGET_TEXT_COLOR": "#DDD",

View File

@@ -25,8 +25,10 @@
"CLEAR_BACKGROUND_COLOR": "#141414",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_TEXT_HIGHLIGHT_COLOR": "#FFF",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#333",
"NODE_DEFAULT_BGCOLOR": "#353535",
"NODE_DEFAULT_BOXCOLOR": "#666",
@@ -35,6 +37,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#222",
"WIDGET_OUTLINE_COLOR": "#666",
"WIDGET_TEXT_COLOR": "#DDD",

View File

@@ -34,7 +34,9 @@
"CLEAR_BACKGROUND_COLOR": "#040506",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#161b22",
"NODE_DEFAULT_BGCOLOR": "#13171d",
"NODE_DEFAULT_BOXCOLOR": "#30363d",
@@ -43,6 +45,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#161b22",
"WIDGET_OUTLINE_COLOR": "#30363d",
"WIDGET_TEXT_COLOR": "#bcc2c8",

View File

@@ -26,8 +26,10 @@
"CLEAR_BACKGROUND_COLOR": "lightgray",
"NODE_TITLE_COLOR": "#222",
"NODE_SELECTED_TITLE_COLOR": "#000",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#444",
"NODE_TEXT_HIGHLIGHT_COLOR": "#1e293b",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#F7F7F7",
"NODE_DEFAULT_BGCOLOR": "#F5F5F5",
"NODE_DEFAULT_BOXCOLOR": "#CCC",
@@ -36,6 +38,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.1)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#D4D4D4",
"WIDGET_OUTLINE_COLOR": "#999",
"WIDGET_TEXT_COLOR": "#222",

View File

@@ -34,7 +34,9 @@
"CLEAR_BACKGROUND_COLOR": "#212732",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2e3440",
"NODE_DEFAULT_BGCOLOR": "#161b22",
"NODE_DEFAULT_BOXCOLOR": "#545d70",
@@ -43,6 +45,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#2e3440",
"WIDGET_OUTLINE_COLOR": "#545d70",
"WIDGET_TEXT_COLOR": "#bcc2c8",

View File

@@ -19,7 +19,9 @@
"litegraph_base": {
"NODE_TITLE_COLOR": "#fdf6e3",
"NODE_SELECTED_TITLE_COLOR": "#A9D400",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#657b83",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#094656",
"NODE_DEFAULT_BGCOLOR": "#073642",
"NODE_DEFAULT_BOXCOLOR": "#839496",
@@ -28,6 +30,7 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#002b36",
"WIDGET_OUTLINE_COLOR": "#839496",
"WIDGET_TEXT_COLOR": "#fdf6e3",

View File

@@ -8,7 +8,7 @@
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 min-w-0 p-0 px-2 pb-2"
class="m-0 min-w-0 p-0 pb-2"
>
<TreeVirtualizer
v-slot="{ item }"

View File

@@ -28,7 +28,7 @@
<button
:class="
cn(
'hover:text-foreground flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
'hover:text-foreground flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
@@ -105,7 +105,7 @@ defineOptions({
})
const ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>

View File

@@ -22,21 +22,19 @@
:model-value="effectiveCurve.points"
:disabled="isDisabled"
:interpolation="effectiveCurve.interpolation"
:histogram="histogram"
@update:model-value="onPointsChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed } from 'vue'
import {
singleValueExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
@@ -65,27 +63,11 @@ const modelValue = defineModel<CurveData>({
const isDisabled = computed(() => !!widget.options?.disabled)
const nodeOutputStore = useNodeOutputStore()
const histogram = computed(() => {
const locatorId = widget.nodeLocatorId
if (!locatorId) return null
const output = nodeOutputStore.nodeOutputs[locatorId]
const data = output?.histogram
if (!Array.isArray(data) || data.length === 0) return null
return new Uint32Array(data)
})
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isCurveData)
)
watch(upstreamValue, (upstream) => {
if (isDisabled.value && upstream) {
modelValue.value = upstream
}
})
const effectiveCurve = computed(() =>
isDisabled.value && upstreamValue.value
? upstreamValue.value

View File

@@ -150,27 +150,21 @@ export function createMonotoneInterpolator(
}
/**
* Convert a histogram (arbitrary number of bins) into an SVG path string.
* Applies square-root scaling and normalizes using the 99.5th percentile
* to avoid outlier spikes.
* Convert a 256-bin histogram into an SVG path string.
* Normalizes using the 99.5th percentile to avoid outlier spikes.
*/
export function histogramToPath(histogram: Uint32Array): string {
const len = histogram.length
if (len === 0) return ''
if (!histogram.length) return ''
const sqrtValues = new Float32Array(len)
for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i])
const sorted = Array.from(sqrtValues).sort((a, b) => a - b)
const max = sorted[Math.floor((len - 1) * 0.995)]
const sorted = Array.from(histogram).sort((a, b) => a - b)
const max = sorted[Math.floor(255 * 0.995)]
if (max === 0) return ''
const invMax = 1 / max
const lastIdx = len - 1
const parts: string[] = ['M0,1']
for (let i = 0; i < len; i++) {
const x = lastIdx === 0 ? 0.5 : i / lastIdx
const y = 1 - Math.min(1, sqrtValues[i] * invMax)
for (let i = 0; i < 256; i++) {
const x = i / 255
const y = 1 - Math.min(1, histogram[i] * invMax)
parts.push(`L${x},${y}`)
}
parts.push('L1,1 Z')

View File

@@ -84,9 +84,7 @@ watch(
pos: group.pos,
size: [group.size[0], group.titleHeight]
})
inputFontStyle.value = {
fontSize: `${LiteGraph.GROUP_TEXT_SIZE * scale}px`
}
inputFontStyle.value = { fontSize: `${group.font_size * scale}px` }
} else if (target instanceof LGraphNode) {
const node = target
const [x, y] = node.getBounding()

View File

@@ -0,0 +1,28 @@
<template>
<span
:class="
cn(
'flex h-5 shrink-0 items-center bg-component-node-widget-background p-1 text-xs',
rest ? 'rounded-l-full pr-1' : 'rounded-full'
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<span class="truncate" v-text="text" />
</span>
<span
v-if="rest"
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
>
<span class="pr-2" v-text="rest" />
</span>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
defineProps<{
text: string
rest?: string
}>()
</script>

View File

@@ -1,9 +1,14 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
<div
ref="previewWrapperRef"
class="origin-top-left"
:style="{ transform: `scale(${scaleFactor})` }"
>
<LGraphNodePreview :node-def="nodeDef" position="relative" />
</div>
</div>
@@ -18,21 +23,21 @@
<!-- Category Path -->
<p
v-if="showCategoryPath && nodeDef.category"
class="-mt-1 text-xs text-muted-foreground"
class="-mt-1 truncate text-xs text-muted-foreground"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
{{ categoryPath }}
</p>
<!-- Badges -->
<div class="flex flex-wrap gap-2 empty:hidden">
<NodePricingBadge :node-def="nodeDef" />
<div class="flex flex-wrap gap-2 overflow-hidden empty:hidden">
<NodePricingBadge class="max-w-full truncate" :node-def="nodeDef" />
<NodeProviderBadge :node-def="nodeDef" />
</div>
<!-- Description -->
<p
v-if="nodeDef.description"
class="m-0 text-[11px] leading-normal font-normal text-muted-foreground"
class="m-0 max-h-[30vh] overflow-y-auto text-xs/normal font-normal text-muted-foreground"
>
{{ nodeDef.description }}
</p>
@@ -99,17 +104,20 @@ import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const SCALE_FACTOR = 0.5
const BASE_WIDTH_PX = 200
const BASE_SCALE = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24
const {
nodeDef,
showInputsAndOutputs = true,
showCategoryPath = false
showCategoryPath = false,
scaleFactor = 0.5
} = defineProps<{
nodeDef: ComfyNodeDefImpl
showInputsAndOutputs?: boolean
showCategoryPath?: boolean
scaleFactor?: number
}>()
const previewContainerRef = ref<HTMLElement>()
@@ -118,11 +126,13 @@ const previewWrapperRef = ref<HTMLElement>()
useResizeObserver(previewWrapperRef, (entries) => {
const entry = entries[0]
if (entry && previewContainerRef.value) {
const scaledHeight = entry.contentRect.height * SCALE_FACTOR
const scaledHeight = entry.contentRect.height * scaleFactor
previewContainerRef.value.style.height = `${scaledHeight + PREVIEW_CONTAINER_PADDING_PX}px`
}
})
const categoryPath = computed(() => nodeDef.category?.replaceAll('/', ' / '))
const inputs = computed(() => {
if (!nodeDef.inputs) return []
return Object.entries(nodeDef.inputs)

View File

@@ -1,18 +1,13 @@
<template>
<BadgePill
v-if="nodeDef.api_node"
v-show="priceLabel"
:text="priceLabel"
icon="icon-[comfy--credits]"
border-style="#f59e0b"
filled
/>
<span v-if="nodeDef.api_node && priceLabel">
<CreditBadge :text="priceLabel" />
</span>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import BadgePill from '@/components/common/BadgePill.vue'
import CreditBadge from '@/components/node/CreditBadge.vue'
import { evaluateNodeDefPricing } from '@/composables/node/useNodePricing'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -7,7 +7,7 @@
:pt="{
root: {
class: useSearchBoxV2
? 'w-4/5 min-w-[32rem] max-w-[56rem] border-0 bg-transparent mt-[10vh] max-md:w-[95%] max-md:min-w-0 overflow-visible'
? 'w-full max-w-[56rem] min-w-[32rem] max-md:min-w-0 bg-transparent border-0 overflow-visible'
: 'invisible-dialog-root'
},
mask: {
@@ -36,7 +36,9 @@
v-if="hoveredNodeDef && enableNodePreview"
:key="hoveredNodeDef.name"
:node-def="hoveredNodeDef"
:scale-factor="0.625"
show-category-path
inert
class="absolute top-0 left-full ml-3"
/>
</div>

View File

@@ -1,32 +1,47 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchCategorySidebar, {
DEFAULT_CATEGORY
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import {
createMockNodeDef,
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => undefined),
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
describe('NodeSearchCategorySidebar', () => {
let wrapper: VueWrapper
beforeEach(() => {
vi.restoreAllMocks()
setupTestPinia()
})
afterEach(() => {
wrapper?.unmount()
})
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: 'most-relevant', ...props },
global: { plugins: [testI18n] }
wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: DEFAULT_CATEGORY, ...props },
global: { plugins: [testI18n] },
attachTo: document.body
})
await nextTick()
return wrapper
@@ -46,30 +61,29 @@ describe('NodeSearchCategorySidebar', () => {
}
describe('preset categories', () => {
it('should render all preset categories', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
essentials_category: 'basic',
python_module: 'comfy_essentials'
})
])
await nextTick()
it('should always show Most relevant', async () => {
const wrapper = await createWrapper()
expect(wrapper.text()).toContain('Most relevant')
expect(wrapper.text()).toContain('Recents')
expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).toContain('Essentials')
expect(wrapper.text()).toContain('Blueprints')
expect(wrapper.text()).toContain('Partner')
expect(wrapper.text()).toContain('Comfy')
expect(wrapper.text()).toContain('Extensions')
})
it('should not show Favorites in sidebar', async () => {
vi.spyOn(useNodeBookmarkStore(), 'bookmarks', 'get').mockReturnValue([
'some-bookmark'
])
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('Favorites')
})
it('should not show source categories in sidebar', async () => {
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('Extensions')
expect(wrapper.text()).not.toContain('Essentials')
})
it('should mark the selected preset category as selected', async () => {
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const wrapper = await createWrapper({
selectedCategory: DEFAULT_CATEGORY
})
const mostRelevantBtn = wrapper.find(
'[data-testid="category-most-relevant"]'
@@ -77,17 +91,6 @@ describe('NodeSearchCategorySidebar', () => {
expect(mostRelevantBtn.attributes('aria-current')).toBe('true')
})
it('should emit update:selectedCategory when preset is clicked', async () => {
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
await clickCategory(wrapper, 'Favorites')
expect(wrapper.emitted('update:selectedCategory')).toBeTruthy()
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'favorites'
])
})
})
describe('category tree', () => {
@@ -127,7 +130,8 @@ describe('NodeSearchCategorySidebar', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' })
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' }),
createMockNodeDef({ name: 'Node4', category: 'loaders' })
])
await nextTick()
@@ -166,7 +170,8 @@ describe('NodeSearchCategorySidebar', () => {
it('should emit update:selectedCategory when subcategory is clicked', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
@@ -202,11 +207,14 @@ describe('NodeSearchCategorySidebar', () => {
it('should emit selected subcategory when expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const wrapper = await createWrapper({
selectedCategory: DEFAULT_CATEGORY
})
// Expand and click subcategory
await clickCategory(wrapper, 'sampling', true)
@@ -217,7 +225,16 @@ describe('NodeSearchCategorySidebar', () => {
})
})
it('should support deeply nested categories (3+ levels)', async () => {
describe('hidePresets prop', () => {
it('should hide preset categories when hidePresets is true', async () => {
const wrapper = await createWrapper({ hidePresets: true })
expect(wrapper.text()).not.toContain('Most relevant')
expect(wrapper.text()).not.toContain('Custom')
})
})
it('should emit autoExpand for single root and support deeply nested categories', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'api' }),
createMockNodeDef({ name: 'Node2', category: 'api/image' }),
@@ -227,14 +244,14 @@ describe('NodeSearchCategorySidebar', () => {
const wrapper = await createWrapper()
// Only top-level visible initially
// Single root emits autoExpand
expect(wrapper.emitted('autoExpand')?.[0]).toEqual(['api'])
// Simulate parent handling autoExpand
await wrapper.setProps({ selectedCategory: 'api' })
await nextTick()
expect(wrapper.text()).toContain('api')
expect(wrapper.text()).not.toContain('image')
expect(wrapper.text()).not.toContain('BFL')
// Expand api
await clickCategory(wrapper, 'api', true)
expect(wrapper.text()).toContain('image')
expect(wrapper.text()).not.toContain('BFL')
@@ -262,4 +279,202 @@ describe('NodeSearchCategorySidebar', () => {
expect(wrapper.emitted('update:selectedCategory')![0][0]).toBe('sampling')
})
describe('keyboard navigation', () => {
it('should expand a collapsed tree node on ArrowRight', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
// Should have emitted select for sampling, expanding it
expect(wrapper.emitted('update:selectedCategory')).toBeTruthy()
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'sampling'
])
})
it('should collapse an expanded tree node on ArrowLeft', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
// First expand sampling by clicking
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
// Collapse toggles internal state; children should be hidden
expect(wrapper.text()).not.toContain('advanced')
})
it('should focus first child on ArrowRight when already expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
const advancedBtn = wrapper.find(
'[data-testid="category-sampling/advanced"]'
)
expect(advancedBtn.element).toBe(document.activeElement)
})
it('should focus parent on ArrowLeft from a leaf or collapsed node', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
const advancedBtn = wrapper.find(
'[data-testid="category-sampling/advanced"]'
)
await advancedBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
expect(samplingBtn.element).toBe(document.activeElement)
})
it('should collapse sampling on ArrowLeft, not just its expanded child', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({
name: 'Node2',
category: 'sampling/custom_sampling'
}),
createMockNodeDef({
name: 'Node3',
category: 'sampling/custom_sampling/child'
}),
createMockNodeDef({ name: 'Node4', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
// Step 1: Expand sampling
await clickCategory(wrapper, 'sampling', true)
await wrapper.setProps({ selectedCategory: 'sampling' })
await nextTick()
expect(wrapper.text()).toContain('custom_sampling')
// Step 2: Expand custom_sampling
await clickCategory(wrapper, 'custom_sampling', true)
await wrapper.setProps({ selectedCategory: 'sampling/custom_sampling' })
await nextTick()
expect(wrapper.text()).toContain('child')
// Step 3: Navigate back to sampling (keyboard focus only)
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
;(samplingBtn.element as HTMLElement).focus()
await nextTick()
// Step 4: Press left on sampling
await samplingBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
// Sampling should collapse entirely — custom_sampling should not be visible
expect(wrapper.text()).not.toContain('custom_sampling')
})
it('should collapse 4-deep tree to parent of level 2 on ArrowLeft', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'N1', category: 'a' }),
createMockNodeDef({ name: 'N2', category: 'a/b' }),
createMockNodeDef({ name: 'N3', category: 'a/b/c' }),
createMockNodeDef({ name: 'N4', category: 'a/b/c/d' }),
createMockNodeDef({ name: 'N5', category: 'other' })
])
await nextTick()
const wrapper = await createWrapper()
// Expand a → a/b → a/b/c → a/b/c/d
await clickCategory(wrapper, 'a', true)
await wrapper.setProps({ selectedCategory: 'a' })
await nextTick()
expect(wrapper.text()).toContain('b')
await clickCategory(wrapper, 'b', true)
await wrapper.setProps({ selectedCategory: 'a/b' })
await nextTick()
expect(wrapper.text()).toContain('c')
await clickCategory(wrapper, 'c', true)
await wrapper.setProps({ selectedCategory: 'a/b/c' })
await nextTick()
expect(wrapper.text()).toContain('d')
// Focus level 2 (a/b) and press ArrowLeft
const bBtn = wrapper.find('[data-testid="category-a/b"]')
;(bBtn.element as HTMLElement).focus()
await nextTick()
await bBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
// Level 2 and below should collapse, but level 1 (a) stays expanded
// so 'b' is still visible but 'c' and 'd' are not
expect(wrapper.text()).toContain('b')
expect(wrapper.text()).not.toContain('c')
expect(wrapper.text()).not.toContain('d')
})
it('should set aria-expanded on tree nodes with children', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
const samplingTreeItem = wrapper
.find('[data-testid="category-sampling"]')
.element.closest('[role="treeitem"]')!
expect(samplingTreeItem.getAttribute('aria-expanded')).toBe('false')
// Leaf node should not have aria-expanded
const loadersTreeItem = wrapper
.find('[data-testid="category-loaders"]')
.element.closest('[role="treeitem"]')!
expect(loadersTreeItem.getAttribute('aria-expanded')).toBeNull()
})
})
})

View File

@@ -1,52 +1,62 @@
<template>
<div class="flex min-h-0 flex-col overflow-y-auto py-2.5">
<RovingFocusGroup
as="div"
orientation="vertical"
:loop="true"
class="group/categories flex min-h-0 flex-col overflow-y-auto py-2.5 select-none"
>
<!-- Preset categories -->
<div class="flex flex-col px-1">
<button
<div v-if="!hidePresets" class="flex flex-col px-3">
<RovingFocusItem
v-for="preset in topCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
as-child
>
{{ preset.label }}
</button>
</div>
<!-- Source categories -->
<div class="my-2 flex flex-col border-y border-border-subtle px-1 py-2">
<button
v-for="preset in sourceCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
<button
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
</RovingFocusItem>
</div>
<!-- Category tree -->
<div class="flex flex-col px-1">
<div
role="tree"
:aria-label="t('g.category')"
:class="
cn(
'flex flex-col px-3',
!hidePresets && 'mt-2 border-t border-border-subtle pt-2'
)
"
>
<NodeSearchCategoryTreeNode
v-for="category in categoryTree"
:key="category.key"
:node="category"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
:expanded-category="expandedCategory"
:hide-chevrons="hideChevrons"
@select="selectCategory"
@collapse="collapseCategory"
/>
</div>
</div>
</RovingFocusGroup>
</template>
<script lang="ts">
export const DEFAULT_CATEGORY = 'most-relevant'
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RovingFocusGroup, RovingFocusItem } from 'reka-ui'
import NodeSearchCategoryTreeNode, {
CATEGORY_SELECTED_CLASS,
@@ -54,52 +64,45 @@ import NodeSearchCategoryTreeNode, {
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const {
hideChevrons = false,
hidePresets = false,
nodeDefs,
rootLabel,
rootKey
} = defineProps<{
hideChevrons?: boolean
hidePresets?: boolean
nodeDefs?: ComfyNodeDefImpl[]
rootLabel?: string
rootKey?: string
}>()
const selectedCategory = defineModel<string>('selectedCategory', {
required: true
})
const emit = defineEmits<{
autoExpand: [key: string]
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
{ id: 'most-relevant', label: t('g.mostRelevant') },
{ id: 'recents', label: t('g.recents') },
{ id: 'favorites', label: t('g.favorites') }
{ id: DEFAULT_CATEGORY, label: t('g.mostRelevant') }
])
const hasEssentialNodes = computed(() =>
nodeDefStore.visibleNodeDefs.some(
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
)
const sourceCategories = computed(() => {
const categories = []
if (hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push(
{
id: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
},
{ id: 'partner', label: t('g.partner') },
{ id: 'comfy', label: t('g.comfy') },
{ id: 'extensions', label: t('g.extensions') }
)
return categories
})
const categoryTree = computed<CategoryNode[]>(() => {
const tree = nodeOrganizationService.organizeNodes(
nodeDefStore.visibleNodeDefs,
{ groupBy: 'category' }
)
const defs = nodeDefs ?? nodeDefStore.visibleNodeDefs
const tree = nodeOrganizationService.organizeNodes(defs, {
groupBy: 'category'
})
const stripRootPrefix = (key: string) => key.replace(/^root\//, '')
@@ -114,28 +117,82 @@ const categoryTree = computed<CategoryNode[]>(() => {
}
}
return (tree.children ?? [])
const nodes = (tree.children ?? [])
.filter((node): node is TreeNode => !node.leaf)
.map(mapNode)
if (rootLabel && nodes.length > 1) {
const key = rootKey ?? rootLabel.toLowerCase()
function prefixKeys(node: CategoryNode): CategoryNode {
return {
key: key + '/' + node.key,
label: node.label,
...(node.children?.length
? { children: node.children.map(prefixKeys) }
: {})
}
}
return [{ key, label: rootLabel, children: nodes.map(prefixKeys) }]
}
return nodes
})
// Notify parent when there is only a single root category to auto-expand
watch(
categoryTree,
(nodes) => {
if (nodes.length === 1 && nodes[0].children?.length) {
const rootKey = nodes[0].key
if (
selectedCategory.value !== rootKey &&
!selectedCategory.value.startsWith(rootKey + '/')
) {
emit('autoExpand', rootKey)
}
}
},
{ immediate: true }
)
function categoryBtnClass(id: string) {
return cn(
'cursor-pointer rounded-sm border-none bg-transparent px-3 py-2.5 text-left text-sm transition-colors',
'cursor-pointer rounded-lg border-none bg-transparent py-2.5 pr-3 text-left font-inter text-sm transition-colors',
hideChevrons ? 'pl-3' : 'pl-9',
selectedCategory.value === id
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
}
const selectedCollapsed = ref(false)
const expandedCategory = ref(selectedCategory.value)
let lastEmittedCategory = ''
watch(selectedCategory, (val) => {
if (val !== lastEmittedCategory) {
expandedCategory.value = val
}
lastEmittedCategory = ''
})
function parentCategory(key: string): string {
const i = key.lastIndexOf('/')
return i > 0 ? key.slice(0, i) : ''
}
function selectCategory(categoryId: string) {
if (selectedCategory.value === categoryId) {
selectedCollapsed.value = !selectedCollapsed.value
if (expandedCategory.value === categoryId) {
expandedCategory.value = parentCategory(categoryId)
} else {
selectedCollapsed.value = false
selectedCategory.value = categoryId
expandedCategory.value = categoryId
}
lastEmittedCategory = categoryId
selectedCategory.value = categoryId
}
function collapseCategory(categoryId: string) {
expandedCategory.value = parentCategory(categoryId)
lastEmittedCategory = categoryId
selectedCategory.value = categoryId
}
</script>

View File

@@ -1,32 +1,66 @@
<template>
<button
type="button"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
<div
:class="
cn(
'w-full cursor-pointer rounded-sm border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
selectedCategory === node.key &&
isExpanded &&
node.children?.length &&
'rounded-lg bg-secondary-background'
)
"
@click="$emit('select', node.key)"
>
{{ node.label }}
</button>
<template v-if="isExpanded && node.children?.length">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
@select="$emit('select', $event)"
/>
</template>
<RovingFocusItem as-child>
<button
ref="buttonEl"
type="button"
role="treeitem"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:aria-expanded="node.children?.length ? isExpanded : undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
:class="
cn(
'flex w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent py-2.5 pr-3 text-left font-inter text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
"
@click="$emit('select', node.key)"
@keydown.right.prevent="handleRight"
@keydown.left.prevent="handleLeft"
>
<i
v-if="!hideChevrons"
:class="
cn(
'size-4 shrink-0 text-muted-foreground transition-[transform,opacity] duration-150',
node.children?.length
? 'icon-[lucide--chevron-down] opacity-0 group-hover/categories:opacity-100 group-has-focus-visible/categories:opacity-100'
: '',
node.children?.length && !isExpanded && '-rotate-90'
)
"
/>
<span class="flex-1 truncate">{{ node.label }}</span>
</button>
</RovingFocusItem>
<div v-if="isExpanded && node.children?.length" role="group">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
ref="childRefs"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:expanded-category="expandedCategory"
:hide-chevrons="hideChevrons"
:focus-parent="() => buttonEl?.focus()"
@select="$emit('select', $event)"
@collapse="$emit('collapse', $event)"
/>
</div>
</div>
</template>
<script lang="ts">
@@ -37,13 +71,14 @@ export interface CategoryNode {
}
export const CATEGORY_SELECTED_CLASS =
'bg-secondary-background-hover font-semibold text-foreground'
'bg-secondary-background-hover text-foreground'
export const CATEGORY_UNSELECTED_CLASS =
'text-muted-foreground hover:bg-secondary-background-hover hover:text-foreground'
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { RovingFocusItem } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
@@ -51,20 +86,53 @@ const {
node,
depth = 0,
selectedCategory,
selectedCollapsed = false
expandedCategory,
hideChevrons = false,
focusParent
} = defineProps<{
node: CategoryNode
depth?: number
selectedCategory: string
selectedCollapsed?: boolean
expandedCategory: string
hideChevrons?: boolean
focusParent?: () => void
}>()
defineEmits<{
const emit = defineEmits<{
select: [key: string]
collapse: [key: string]
}>()
const isExpanded = computed(() => {
if (selectedCategory === node.key) return !selectedCollapsed
return selectedCategory.startsWith(node.key + '/')
})
const buttonEl = ref<HTMLButtonElement>()
const childRefs = ref<{ focus?: () => void }[]>([])
defineExpose({ focus: () => buttonEl.value?.focus() })
const isExpanded = computed(
() =>
expandedCategory === node.key || expandedCategory.startsWith(node.key + '/')
)
function handleRight() {
if (!node.children?.length) return
if (!isExpanded.value) {
emit('select', node.key)
return
}
nextTick(() => {
childRefs.value[0]?.focus?.()
})
}
function handleLeft() {
if (node.children?.length && isExpanded.value) {
if (expandedCategory.startsWith(node.key + '/')) {
emit('collapse', node.key)
} else {
emit('select', node.key)
}
return
}
focusParent?.()
}
</script>

View File

@@ -3,8 +3,8 @@ import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import {
createMockNodeDef,
setupTestPinia,
@@ -55,13 +55,35 @@ describe('NodeSearchContent', () => {
return wrapper
}
function mockBookmarks(
isBookmarked: boolean | ((node: ComfyNodeDefImpl) => boolean) = true,
bookmarkList: string[] = []
) {
const bookmarkStore = useNodeBookmarkStore()
if (typeof isBookmarked === 'function') {
vi.spyOn(bookmarkStore, 'isBookmarked').mockImplementation(isBookmarked)
} else {
vi.spyOn(bookmarkStore, 'isBookmarked').mockReturnValue(isBookmarked)
}
vi.spyOn(bookmarkStore, 'bookmarks', 'get').mockReturnValue(bookmarkList)
}
function clickFilterButton(wrapper: VueWrapper, text: string) {
const btn = wrapper
.findComponent(NodeSearchFilterBar)
.findAll('button')
.find((b) => b.text() === text)
expect(btn, `Expected filter button "${text}"`).toBeDefined()
return btn!.trigger('click')
}
async function setupFavorites(
nodes: Parameters<typeof createMockNodeDef>[0][]
) {
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
mockBookmarks(true, ['placeholder'])
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
return wrapper
}
@@ -106,12 +128,13 @@ describe('NodeSearchContent', () => {
display_name: 'Regular Node'
})
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockImplementation(
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
mockBookmarks(
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode',
['BookmarkedNode']
)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
const items = getNodeItems(wrapper)
@@ -123,83 +146,15 @@ describe('NodeSearchContent', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', display_name: 'Node One' })
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
mockBookmarks(false, ['placeholder'])
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
expect(wrapper.text()).toContain('No results')
})
it('should show only CustomNodes when Extensions is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
display_name: 'Core Node',
python_module: 'nodes'
}),
createMockNodeDef({
name: 'CustomNode',
display_name: 'Custom Node',
python_module: 'custom_nodes.my_extension'
})
])
await nextTick()
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
NodeSourceType.Core
)
expect(
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
).toBe(NodeSourceType.CustomNodes)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-extensions"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Custom Node')
})
it('should hide Essentials category when no essential nodes exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
const wrapper = await createWrapper()
expect(wrapper.find('[data-testid="category-essentials"]').exists()).toBe(
false
)
})
it('should show only essential nodes when Essentials is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
await nextTick()
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-essentials"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Essential Node')
})
it('should include subcategory nodes when parent category is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
@@ -230,8 +185,137 @@ describe('NodeSearchContent', () => {
})
})
describe('root filter (filter bar categories)', () => {
it('should show only non-Core nodes when Extensions root filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
display_name: 'Core Node',
python_module: 'nodes'
}),
createMockNodeDef({
name: 'CustomNode',
display_name: 'Custom Node',
python_module: 'custom_nodes.my_extension'
})
])
await nextTick()
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
NodeSourceType.Core
)
expect(
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
).toBe(NodeSourceType.CustomNodes)
const wrapper = await createWrapper()
const extensionsBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Extensions'))
expect(extensionsBtn).toBeTruthy()
await extensionsBtn!.trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Custom Node')
})
it('should show only essential nodes when Essentials root filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
await nextTick()
const wrapper = await createWrapper()
const filterBar = wrapper.findComponent(NodeSearchFilterBar)
const essentialsBtn = filterBar
.findAll('button')
.find((b) => b.text().includes('Essentials'))
expect(essentialsBtn).toBeTruthy()
await essentialsBtn!.trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Essential Node')
})
it('should show only API nodes when Partner Nodes root filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ApiNode',
display_name: 'API Node',
api_node: true
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
const wrapper = await createWrapper()
const filterBar = wrapper.findComponent(NodeSearchFilterBar)
const partnerBtn = filterBar
.findAll('button')
.find((b) => b.text().includes('Partner'))
expect(partnerBtn).toBeTruthy()
await partnerBtn!.trigger('click')
await nextTick()
const items = getNodeItems(wrapper)
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('API Node')
})
it('should toggle root filter off when clicking the active category button', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
display_name: 'Core Node',
python_module: 'nodes'
}),
createMockNodeDef({
name: 'CustomNode',
display_name: 'Custom Node',
python_module: 'custom_nodes.my_extension'
})
])
await nextTick()
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['CoreNode'],
useNodeDefStore().nodeDefsByName['CustomNode']
])
const wrapper = await createWrapper()
const filterBar = wrapper.findComponent(NodeSearchFilterBar)
const extensionsBtn = filterBar
.findAll('button')
.find((b) => b.text().includes('Extensions'))!
// Activate
await extensionsBtn.trigger('click')
await nextTick()
expect(getNodeItems(wrapper)).toHaveLength(1)
// Deactivate (toggle off)
await extensionsBtn.trigger('click')
await nextTick()
expect(getNodeItems(wrapper)).toHaveLength(2)
})
})
describe('search and category interaction', () => {
it('should override category to most-relevant when search query is active', async () => {
it('should search within selected category', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
@@ -256,13 +340,14 @@ describe('NodeSearchContent', () => {
await nextTick()
const texts = getNodeItems(wrapper).map((i) => i.text())
expect(texts.some((t) => t.includes('Load Checkpoint'))).toBe(true)
expect(texts.some((t) => t.includes('Load Checkpoint'))).toBe(false)
})
it('should clear search query when category changes', async () => {
it('should preserve search query when category changes', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
])
mockBookmarks(true, ['placeholder'])
const wrapper = await createWrapper()
@@ -271,9 +356,9 @@ describe('NodeSearchContent', () => {
await nextTick()
expect((input.element as HTMLInputElement).value).toBe('test query')
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
expect((input.element as HTMLInputElement).value).toBe('')
expect((input.element as HTMLInputElement).value).toBe('test query')
})
it('should reset selected index when search query changes', async () => {
@@ -306,11 +391,10 @@ describe('NodeSearchContent', () => {
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
await wrapper
.find('[data-testid="category-most-relevant"]')
.trigger('click')
// Toggle Bookmarked off (back to default) then on again to reset index
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
@@ -373,19 +457,63 @@ describe('NodeSearchContent', () => {
})
})
it('should select item on hover', async () => {
it('should select item on hover via pointermove', async () => {
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' }
])
const results = getResultItems(wrapper)
await results[1].trigger('mouseenter')
await results[1].trigger('pointermove')
await nextTick()
expect(results[1].attributes('aria-selected')).toBe('true')
})
it('should navigate results with ArrowDown/ArrowUp from a focused result item', async () => {
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' },
{ name: 'Node3', display_name: 'Node Three' }
])
const results = getResultItems(wrapper)
await results[0].trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await getResultItems(wrapper)[1].trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getResultItems(wrapper)[2].attributes('aria-selected')).toBe(
'true'
)
await getResultItems(wrapper)[2].trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
})
it('should select node with Enter from a focused result item', async () => {
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await getResultItems(wrapper)[0].trigger('keydown', { key: 'Enter' })
await nextTick()
expect(wrapper.emitted('addNode')).toBeTruthy()
expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
name: 'TestNode'
})
})
it('should add node on click', async () => {
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
@@ -413,10 +541,10 @@ describe('NodeSearchContent', () => {
})
it('should emit null hoverNode when no results', async () => {
mockBookmarks(false, ['placeholder'])
const wrapper = await createWrapper()
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
await clickFilterButton(wrapper, 'Bookmarked')
await nextTick()
const emitted = wrapper.emitted('hoverNode')!
@@ -509,221 +637,4 @@ describe('NodeSearchContent', () => {
})
})
})
describe('filter selection mode', () => {
function setupNodesWithTypes() {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
display_name: 'Image Node',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE']
}),
createMockNodeDef({
name: 'LatentNode',
display_name: 'Latent Node',
input: { required: { latent: ['LATENT', {}] } },
output: ['LATENT']
}),
createMockNodeDef({
name: 'ModelNode',
display_name: 'Model Node',
input: { required: { model: ['MODEL', {}] } },
output: ['MODEL']
})
])
}
function findFilterBarButton(wrapper: VueWrapper, label: string) {
return wrapper
.findAll('button[aria-pressed]')
.find((b) => b.text() === label)
}
async function enterFilterMode(wrapper: VueWrapper) {
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
}
function getFilterOptions(wrapper: VueWrapper) {
return wrapper.findAll('[data-testid="filter-option"]')
}
function getFilterOptionTexts(wrapper: VueWrapper) {
return getFilterOptions(wrapper).map(
(o) =>
o
.findAll('span')[0]
?.text()
.replace(/^[•·]\s*/, '')
.trim() ?? ''
)
}
function hasSidebar(wrapper: VueWrapper) {
return wrapper.findComponent(NodeSearchCategorySidebar).exists()
}
it('should enter filter mode when a filter chip is selected', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
expect(hasSidebar(wrapper)).toBe(true)
await enterFilterMode(wrapper)
expect(hasSidebar(wrapper)).toBe(false)
expect(getFilterOptions(wrapper).length).toBeGreaterThan(0)
})
it('should show available filter options sorted alphabetically', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const texts = getFilterOptionTexts(wrapper)
expect(texts).toContain('IMAGE')
expect(texts).toContain('LATENT')
expect(texts).toContain('MODEL')
expect(texts).toEqual([...texts].sort())
})
it('should filter options when typing in filter mode', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper.find('input[type="text"]').setValue('IMAGE')
await nextTick()
const texts = getFilterOptionTexts(wrapper)
expect(texts).toContain('IMAGE')
expect(texts).not.toContain('MODEL')
})
it('should show no results when filter query has no matches', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper.find('input[type="text"]').setValue('NONEXISTENT_TYPE')
await nextTick()
expect(wrapper.text()).toContain('No results')
})
it('should emit addFilter when a filter option is clicked', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const imageOption = getFilterOptions(wrapper).find((o) =>
o.text().includes('IMAGE')
)
await imageOption!.trigger('click')
await nextTick()
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
})
it('should exit filter mode after applying a filter', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await getFilterOptions(wrapper)[0].trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
it('should emit addFilter when Enter is pressed on selected option', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await wrapper
.find('input[type="text"]')
.trigger('keydown', { key: 'Enter' })
await nextTick()
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
})
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const input = wrapper.find('input[type="text"]')
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getFilterOptions(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await input.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
'true'
)
})
it('should toggle filter mode off when same chip is clicked again', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
it('should reset filter query when re-entering filter mode', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
const input = wrapper.find('input[type="text"]')
await input.setValue('IMAGE')
await nextTick()
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
await nextTick()
await nextTick()
await enterFilterMode(wrapper)
expect((input.element as HTMLInputElement).value).toBe('')
})
it('should exit filter mode when cancel button is clicked', async () => {
setupNodesWithTypes()
const wrapper = await createWrapper()
await enterFilterMode(wrapper)
expect(hasSidebar(wrapper)).toBe(false)
const cancelBtn = wrapper.find('[data-testid="cancel-filter"]')
await cancelBtn.trigger('click')
await nextTick()
await nextTick()
expect(hasSidebar(wrapper)).toBe(true)
})
})
})

View File

@@ -1,107 +1,130 @@
<template>
<div
ref="dialogRef"
class="flex max-h-[50vh] min-h-[400px] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
>
<!-- Search input row -->
<NodeSearchInput
ref="searchInputRef"
v-model:search-query="searchQuery"
v-model:filter-query="filterQuery"
:filters="filters"
:active-filter="activeFilter"
@remove-filter="emit('removeFilter', $event)"
@cancel-filter="cancelFilter"
@navigate-down="onKeyDown"
@navigate-up="onKeyUp"
@select-current="onKeyEnter"
/>
<!-- Filter header row -->
<div class="flex items-center">
<NodeSearchFilterBar
class="flex-1"
:active-chip-key="activeFilter?.key"
@select-chip="onSelectFilterChip"
/>
</div>
<!-- Content area -->
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar (hidden in filter mode) -->
<NodeSearchCategorySidebar
v-if="!activeFilter"
v-model:selected-category="sidebarCategory"
class="w-52 shrink-0"
<FocusScope as-child loop>
<div
ref="dialogRef"
class="flex h-[min(80vh,750px)] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
>
<!-- Search input row -->
<NodeSearchInput
ref="searchInputRef"
v-model:search-query="searchQuery"
:filters="filters"
@remove-filter="emit('removeFilter', $event)"
@navigate-down="navigateResults(1)"
@navigate-up="navigateResults(-1)"
@select-current="selectCurrentResult"
/>
<!-- Filter options list (filter selection mode) -->
<NodeSearchFilterPanel
v-if="activeFilter"
ref="filterPanelRef"
v-model:query="filterQuery"
:chip="activeFilter"
@apply="onFilterApply"
/>
<!-- Filter header row -->
<div class="flex items-center">
<NodeSearchFilterBar
class="flex-1"
:filters="filters"
:active-category="rootFilter"
:has-favorites="nodeBookmarkStore.bookmarks.length > 0"
:has-essential-nodes="nodeAvailability.essential"
:has-blueprint-nodes="nodeAvailability.blueprint"
:has-partner-nodes="nodeAvailability.partner"
:has-custom-nodes="nodeAvailability.custom"
@toggle-filter="onToggleFilter"
@clear-filter-group="onClearFilterGroup"
@focus-search="nextTick(() => searchInputRef?.focus())"
@select-category="onSelectCategory"
/>
</div>
<!-- Results list (normal mode) -->
<div
v-else
id="results-list"
role="listbox"
class="flex-1 overflow-y-auto py-2"
>
<!-- Content area -->
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar -->
<NodeSearchCategorySidebar
v-model:selected-category="sidebarCategory"
class="w-52 shrink-0"
:hide-chevrons="!anyTreeCategoryHasChildren"
:hide-presets="rootFilter !== null"
:node-defs="rootFilteredNodeDefs"
:root-label="rootFilterLabel"
:root-key="rootFilter ?? undefined"
@auto-expand="selectedCategory = $event"
/>
<!-- Results list -->
<div
v-for="(node, index) in displayedResults"
:id="`result-item-${index}`"
:key="node.name"
role="option"
data-testid="result-item"
:aria-selected="index === selectedIndex"
:class="
cn(
'flex h-14 cursor-pointer items-center px-4',
index === selectedIndex && 'bg-secondary-background-hover'
)
"
@click="emit('addNode', node, $event)"
@mouseenter="selectedIndex = index"
id="results-list"
role="listbox"
tabindex="-1"
class="flex-1 overflow-y-auto py-2 pr-3 pl-1 select-none"
@pointermove="onPointerMove"
>
<NodeSearchListItem
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="effectiveCategory !== 'essentials'"
:hide-bookmark-icon="effectiveCategory === 'favorites'"
/>
</div>
<div
v-if="displayedResults.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
<div
v-for="(node, index) in displayedResults"
:id="`result-item-${index}`"
:key="node.name"
role="option"
data-testid="result-item"
:tabindex="index === selectedIndex ? 0 : -1"
:aria-selected="index === selectedIndex"
:class="
cn(
'flex h-14 cursor-pointer items-center rounded-lg px-4 outline-none focus-visible:ring-2 focus-visible:ring-primary',
index === selectedIndex && 'bg-secondary-background'
)
"
@click="emit('addNode', node, $event)"
@keydown.down.prevent="navigateResults(1, true)"
@keydown.up.prevent="navigateResults(-1, true)"
@keydown.enter.prevent="selectCurrentResult"
>
<NodeSearchListItem
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="rootFilter !== 'essentials'"
:hide-bookmark-icon="effectiveCategory === 'favorites'"
/>
</div>
<div
v-if="displayedResults.length === 0"
data-testid="no-results"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
</div>
</div>
</div>
</div>
</div>
</FocusScope>
</template>
<script setup lang="ts">
import { FocusScope } from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchFilterPanel from '@/components/searchbox/v2/NodeSearchFilterPanel.vue'
import NodeSearchCategorySidebar, {
DEFAULT_CATEGORY
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
BLUEPRINT_CATEGORY,
isCustomNode,
isEssentialNode
} from '@/types/nodeSource'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import { cn } from '@/utils/tailwindUtil'
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
{
essentials: isEssentialNode,
comfy: (n) => !isCustomNode(n),
custom: isCustomNode
}
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
}>()
@@ -113,57 +136,102 @@ const emit = defineEmits<{
hoverNode: [nodeDef: ComfyNodeDefImpl | null]
}>()
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeAvailability = computed(() => {
let essential = false
let blueprint = false
let partner = false
let custom = false
for (const n of nodeDefStore.visibleNodeDefs) {
if (!essential && flags.nodeLibraryEssentialsEnabled && isEssentialNode(n))
essential = true
if (!blueprint && n.category.startsWith(BLUEPRINT_CATEGORY))
blueprint = true
if (!partner && n.api_node) partner = true
if (!custom && isCustomNode(n)) custom = true
if (essential && blueprint && partner && custom) break
}
return { essential, blueprint, partner, custom }
})
const dialogRef = ref<HTMLElement>()
const searchInputRef = ref<InstanceType<typeof NodeSearchInput>>()
const filterPanelRef = ref<InstanceType<typeof NodeSearchFilterPanel>>()
const searchQuery = ref('')
const selectedCategory = ref('most-relevant')
const selectedCategory = ref(DEFAULT_CATEGORY)
const selectedIndex = ref(0)
const activeFilter = ref<FilterChip | null>(null)
const filterQuery = ref('')
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<string | null>(null)
function lockDialogHeight() {
if (dialogRef.value) {
dialogRef.value.style.height = `${dialogRef.value.offsetHeight}px`
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {
case 'favorites':
return t('g.bookmarked')
case BLUEPRINT_CATEGORY:
return t('g.blueprints')
case 'partner-nodes':
return t('g.partner')
case 'essentials':
return t('g.essentials')
case 'comfy':
return t('g.comfy')
case 'custom':
return t('g.extensions')
default:
return undefined
}
})
const rootFilteredNodeDefs = computed(() => {
if (!rootFilter.value) return nodeDefStore.visibleNodeDefs
const allNodes = nodeDefStore.visibleNodeDefs
const sourceFilter = sourceCategoryFilters[rootFilter.value]
if (sourceFilter) return allNodes.filter(sourceFilter)
switch (rootFilter.value) {
case 'favorites':
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
case BLUEPRINT_CATEGORY:
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
case 'partner-nodes':
return allNodes.filter((n) => n.api_node)
default:
return allNodes
}
})
function onToggleFilter(
filterDef: FuseFilter<ComfyNodeDefImpl, string>,
value: string
) {
const existing = filters.find(
(f) => f.filterDef.id === filterDef.id && f.value === value
)
if (existing) {
emit('removeFilter', existing)
} else {
emit('addFilter', { filterDef, value })
}
}
function unlockDialogHeight() {
if (dialogRef.value) {
dialogRef.value.style.height = ''
function onClearFilterGroup(filterId: string) {
for (const f of filters.filter((f) => f.filterDef.id === filterId)) {
emit('removeFilter', f)
}
}
function onSelectFilterChip(chip: FilterChip) {
if (activeFilter.value?.key === chip.key) {
cancelFilter()
return
function onSelectCategory(category: string) {
if (rootFilter.value === category) {
rootFilter.value = null
} else {
rootFilter.value = category
}
lockDialogHeight()
activeFilter.value = chip
filterQuery.value = ''
nextTick(() => searchInputRef.value?.focus())
}
function onFilterApply(value: string) {
if (!activeFilter.value) return
emit('addFilter', { filterDef: activeFilter.value.filter, value })
activeFilter.value = null
filterQuery.value = ''
unlockDialogHeight()
nextTick(() => searchInputRef.value?.focus())
}
function cancelFilter() {
activeFilter.value = null
filterQuery.value = ''
unlockDialogHeight()
selectedCategory.value = DEFAULT_CATEGORY
nextTick(() => searchInputRef.value?.focus())
}
@@ -176,67 +244,70 @@ const searchResults = computed(() => {
})
})
const effectiveCategory = computed(() =>
searchQuery.value ? 'most-relevant' : selectedCategory.value
)
const effectiveCategory = computed(() => selectedCategory.value)
const sidebarCategory = computed({
get: () => effectiveCategory.value,
set: (category: string) => {
selectedCategory.value = category
searchQuery.value = ''
}
})
function matchesFilters(node: ComfyNodeDefImpl): boolean {
return filters.every(({ filterDef, value }) => filterDef.matches(node, value))
// Check if any tree category has children (for chevron visibility)
const anyTreeCategoryHasChildren = computed(() =>
rootFilteredNodeDefs.value.some((n) => n.category.includes('/'))
)
function getMostRelevantResults(baseNodes: ComfyNodeDefImpl[]) {
if (searchQuery.value || filters.length > 0) {
const searched = searchResults.value
if (!rootFilter.value) return searched
const rootSet = new Set(baseNodes.map((n) => n.name))
return searched.filter((n) => rootSet.has(n.name))
}
return rootFilter.value ? baseNodes : nodeFrequencyStore.topNodeDefs
}
function getCategoryResults(baseNodes: ComfyNodeDefImpl[], category: string) {
if (rootFilter.value && category === rootFilter.value) return baseNodes
const rootPrefix = rootFilter.value ? rootFilter.value + '/' : ''
const categoryPath = category.startsWith(rootPrefix)
? category.slice(rootPrefix.length)
: category
return baseNodes.filter((n) => {
const nodeCategory = n.category.startsWith(rootPrefix)
? n.category.slice(rootPrefix.length)
: n.category
return (
nodeCategory === categoryPath ||
nodeCategory.startsWith(categoryPath + '/')
)
})
}
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
const allNodes = nodeDefStore.visibleNodeDefs
const baseNodes = rootFilteredNodeDefs.value
const category = effectiveCategory.value
let results: ComfyNodeDefImpl[]
switch (effectiveCategory.value) {
case 'most-relevant':
return searchResults.value
case 'favorites':
results = allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
break
case 'essentials':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
break
case 'recents':
return searchResults.value
case 'blueprints':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Blueprint
)
break
case 'partner':
results = allNodes.filter((n) => n.api_node)
break
case 'comfy':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Core
)
break
case 'extensions':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.CustomNodes
)
break
default:
results = allNodes.filter(
(n) =>
n.category === effectiveCategory.value ||
n.category.startsWith(effectiveCategory.value + '/')
)
break
if (category === DEFAULT_CATEGORY) return getMostRelevantResults(baseNodes)
const hasSearch = searchQuery.value || filters.length > 0
let source: ComfyNodeDefImpl[]
if (hasSearch) {
const searched = searchResults.value
if (rootFilter.value) {
const rootSet = new Set(baseNodes.map((n) => n.name))
source = searched.filter((n) => rootSet.has(n.name))
} else {
source = searched
}
} else {
source = baseNodes
}
return filters.length > 0 ? results.filter(matchesFilters) : results
const sourceFilter = sourceCategoryFilters[category]
if (sourceFilter) return source.filter(sourceFilter)
return getCategoryResults(source, category)
})
const hoveredNodeDef = computed(
@@ -251,42 +322,28 @@ watch(
{ immediate: true }
)
watch([selectedCategory, searchQuery, () => filters], () => {
watch([selectedCategory, searchQuery, rootFilter, () => filters.length], () => {
selectedIndex.value = 0
})
function onKeyDown() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(1)
} else {
navigateResults(1)
}
function onPointerMove(event: PointerEvent) {
const item = (event.target as HTMLElement).closest('[role=option]')
if (!item) return
const index = Number(item.id.replace('result-item-', ''))
if (!isNaN(index) && index !== selectedIndex.value)
selectedIndex.value = index
}
function onKeyUp() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(-1)
} else {
navigateResults(-1)
}
}
function onKeyEnter() {
if (activeFilter.value) {
filterPanelRef.value?.selectCurrent()
} else {
selectCurrentResult()
}
}
function navigateResults(direction: number) {
function navigateResults(direction: number, focusItem = false) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < displayedResults.value.length) {
selectedIndex.value = newIndex
nextTick(() => {
dialogRef.value
?.querySelector(`#result-item-${newIndex}`)
?.scrollIntoView({ block: 'nearest' })
const el = dialogRef.value?.querySelector(
`#result-item-${newIndex}`
) as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
if (focusItem) el?.focus()
})
}
}

View File

@@ -12,7 +12,11 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => undefined),
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
@@ -33,51 +37,79 @@ describe(NodeSearchFilterBar, () => {
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchFilterBar, {
props,
global: { plugins: [testI18n] }
global: {
plugins: [testI18n],
stubs: {
NodeSearchTypeFilterPopover: {
template: '<div data-testid="popover"><slot /></div>',
props: ['chip', 'selectedValues']
}
}
}
})
await nextTick()
return wrapper
}
it('should render all filter chips', async () => {
it('should render Extensions button and Input/Output popover triggers', async () => {
const wrapper = await createWrapper({ hasCustomNodes: true })
const buttons = wrapper.findAll('button')
const texts = buttons.map((b) => b.text())
expect(texts).toContain('Extensions')
expect(texts).toContain('Input')
expect(texts).toContain('Output')
})
it('should always render Comfy button', async () => {
const wrapper = await createWrapper()
const texts = wrapper.findAll('button').map((b) => b.text())
expect(texts).toContain('Comfy')
})
it('should render conditional category buttons when matching nodes exist', async () => {
const wrapper = await createWrapper({
hasFavorites: true,
hasEssentialNodes: true,
hasBlueprintNodes: true,
hasPartnerNodes: true
})
const texts = wrapper.findAll('button').map((b) => b.text())
expect(texts).toContain('Bookmarked')
expect(texts).toContain('Blueprints')
expect(texts).toContain('Partner')
expect(texts).toContain('Essentials')
})
it('should not render Extensions button when no custom nodes exist', async () => {
const wrapper = await createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(6)
expect(buttons[0].text()).toBe('Blueprints')
expect(buttons[1].text()).toBe('Partner Nodes')
expect(buttons[2].text()).toBe('Essentials')
expect(buttons[3].text()).toBe('Extensions')
expect(buttons[4].text()).toBe('Input')
expect(buttons[5].text()).toBe('Output')
const texts = buttons.map((b) => b.text())
expect(texts).not.toContain('Extensions')
})
it('should mark active chip as pressed when activeChipKey matches', async () => {
const wrapper = await createWrapper({ activeChipKey: 'input' })
it('should emit selectCategory when category button is clicked', async () => {
const wrapper = await createWrapper({ hasCustomNodes: true })
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
expect(inputBtn?.attributes('aria-pressed')).toBe('true')
const extensionsBtn = wrapper
.findAll('button')
.find((b) => b.text() === 'Extensions')!
await extensionsBtn.trigger('click')
expect(wrapper.emitted('selectCategory')![0]).toEqual(['custom'])
})
it('should not mark chips as pressed when activeChipKey does not match', async () => {
const wrapper = await createWrapper({ activeChipKey: null })
wrapper.findAll('button').forEach((btn) => {
expect(btn.attributes('aria-pressed')).toBe('false')
it('should apply active styling when activeCategory matches', async () => {
const wrapper = await createWrapper({
activeCategory: 'custom',
hasCustomNodes: true
})
})
it('should emit selectChip with chip data when clicked', async () => {
const wrapper = await createWrapper()
const extensionsBtn = wrapper
.findAll('button')
.find((b) => b.text() === 'Extensions')!
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
await inputBtn?.trigger('click')
const emitted = wrapper.emitted('selectChip')!
expect(emitted[0][0]).toMatchObject({
key: 'input',
label: 'Input',
filter: expect.anything()
})
expect(extensionsBtn.attributes('aria-pressed')).toBe('true')
})
})

View File

@@ -1,22 +1,43 @@
<template>
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex items-center gap-2.5 px-3">
<!-- Category filter buttons -->
<button
v-for="chip in chips"
:key="chip.key"
v-for="btn in categoryButtons"
:key="btn.id"
type="button"
:aria-pressed="activeChipKey === chip.key"
:class="
cn(
'flex-auto cursor-pointer rounded-md border border-secondary-background px-3 py-1 text-sm transition-colors',
activeChipKey === chip.key
? 'text-foreground bg-secondary-background'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
"
@click="emit('selectChip', chip)"
:aria-pressed="activeCategory === btn.id"
:class="chipClass(activeCategory === btn.id)"
@click="emit('selectCategory', btn.id)"
>
{{ chip.label }}
{{ btn.label }}
</button>
<div class="h-5 w-px shrink-0 bg-border-subtle" />
<!-- Type filter popovers (Input / Output) -->
<NodeSearchTypeFilterPopover
v-for="tf in typeFilters"
:key="tf.chip.key"
:chip="tf.chip"
:selected-values="tf.values"
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
@escape-close="emit('focusSearch')"
>
<button type="button" :class="chipClass(false, tf.values.length > 0)">
<span v-if="tf.values.length > 0" class="flex items-center">
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
:key="val"
class="-mx-[2px] text-lg leading-none"
:style="{ color: getLinkTypeColor(val) }"
>&bull;</span
>
</span>
{{ tf.chip.label }}
<i class="icon-[lucide--chevron-down] size-3.5" />
</button>
</NodeSearchTypeFilterPopover>
</div>
</template>
@@ -35,53 +56,97 @@ export interface FilterChip {
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { BLUEPRINT_CATEGORY } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const { activeChipKey = null } = defineProps<{
activeChipKey?: string | null
const {
filters = [],
activeCategory = null,
hasFavorites = false,
hasEssentialNodes = false,
hasBlueprintNodes = false,
hasPartnerNodes = false,
hasCustomNodes = false
} = defineProps<{
filters?: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeCategory?: string | null
hasFavorites?: boolean
hasEssentialNodes?: boolean
hasBlueprintNodes?: boolean
hasPartnerNodes?: boolean
hasCustomNodes?: boolean
}>()
const emit = defineEmits<{
selectChip: [chip: FilterChip]
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
clearFilterGroup: [filterId: string]
focusSearch: []
selectCategory: [category: string]
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const chips = computed<FilterChip[]>(() => {
const searchService = nodeDefStore.nodeSearchService
return [
{
key: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints'),
filter: searchService.nodeSourceFilter
},
{
key: 'partnerNodes',
label: t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'),
filter: searchService.nodeSourceFilter
},
{
key: 'essentials',
label: t('g.essentials'),
filter: searchService.nodeSourceFilter
},
{
key: 'extensions',
label: t('g.extensions'),
filter: searchService.nodeSourceFilter
},
{
key: 'input',
label: t('g.input'),
filter: searchService.inputTypeFilter
},
{
key: 'output',
label: t('g.output'),
filter: searchService.outputTypeFilter
}
]
const MAX_VISIBLE_DOTS = 4
const categoryButtons = computed(() => {
const buttons: { id: string; label: string }[] = []
if (hasFavorites) {
buttons.push({ id: 'favorites', label: t('g.bookmarked') })
}
if (hasBlueprintNodes) {
buttons.push({ id: BLUEPRINT_CATEGORY, label: t('g.blueprints') })
}
if (hasPartnerNodes) {
buttons.push({ id: 'partner-nodes', label: t('g.partner') })
}
if (hasEssentialNodes) {
buttons.push({ id: 'essentials', label: t('g.essentials') })
}
buttons.push({ id: 'comfy', label: t('g.comfy') })
if (hasCustomNodes) {
buttons.push({ id: 'custom', label: t('g.extensions') })
}
return buttons
})
const inputChip = computed<FilterChip>(() => ({
key: 'input',
label: t('g.input'),
filter: nodeDefStore.nodeSearchService.inputTypeFilter
}))
const outputChip = computed<FilterChip>(() => ({
key: 'output',
label: t('g.output'),
filter: nodeDefStore.nodeSearchService.outputTypeFilter
}))
const selectedInputValues = computed(() =>
filters.filter((f) => f.filterDef.id === 'input').map((f) => f.value)
)
const selectedOutputValues = computed(() =>
filters.filter((f) => f.filterDef.id === 'output').map((f) => f.value)
)
const typeFilters = computed(() => [
{ chip: inputChip.value, values: selectedInputValues.value },
{ chip: outputChip.value, values: selectedOutputValues.value }
])
function chipClass(isActive: boolean, hasSelections = false) {
return cn(
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
isActive
? 'border-base-foreground bg-base-foreground text-base-background'
: hasSelections
? 'border-base-foreground/60 bg-transparent text-base-foreground/60 hover:border-base-foreground/60 hover:text-base-foreground/60'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
}
</script>

View File

@@ -1,90 +0,0 @@
<template>
<div
id="filter-options-list"
ref="listRef"
role="listbox"
class="flex-1 overflow-y-auto py-2"
>
<div
v-for="(option, index) in options"
:id="`filter-option-${index}`"
:key="option"
role="option"
data-testid="filter-option"
:aria-selected="index === selectedIndex"
:class="
cn(
'cursor-pointer px-6 py-1.5',
index === selectedIndex && 'bg-secondary-background-hover'
)
"
@click="emit('apply', option)"
@mouseenter="selectedIndex = index"
>
<span class="text-foreground text-base font-semibold">
<span class="mr-1 text-2xl" :style="{ color: getLinkTypeColor(option) }"
>&bull;</span
>
{{ option }}
</span>
</div>
<div
v-if="options.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const { chip } = defineProps<{
chip: FilterChip
}>()
const query = defineModel<string>('query', { required: true })
const emit = defineEmits<{
apply: [value: string]
}>()
const listRef = ref<HTMLElement>()
const selectedIndex = ref(0)
const options = computed(() => {
const { fuseSearch } = chip.filter
if (query.value) {
return fuseSearch.search(query.value).slice(0, 64)
}
return fuseSearch.data.slice().sort()
})
watch(query, () => {
selectedIndex.value = 0
})
function navigate(direction: number) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < options.value.length) {
selectedIndex.value = newIndex
nextTick(() => {
listRef.value
?.querySelector(`#filter-option-${newIndex}`)
?.scrollIntoView({ block: 'nearest' })
})
}
}
function selectCurrent() {
const option = options.value[selectedIndex.value]
if (option) emit('apply', option)
}
defineExpose({ navigate, selectCurrent })
</script>

View File

@@ -1,7 +1,6 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import {
setupTestPinia,
@@ -18,7 +17,11 @@ vi.mock('@/utils/litegraphUtil', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(),
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
@@ -39,20 +42,6 @@ function createFilter(
}
}
function createActiveFilter(label: string): FilterChip {
return {
key: label.toLowerCase(),
label,
filter: {
id: label.toLowerCase(),
matches: vi.fn(() => true)
} as Partial<FuseFilter<ComfyNodeDefImpl, string>> as FuseFilter<
ComfyNodeDefImpl,
string
>
}
}
describe('NodeSearchInput', () => {
beforeEach(() => {
setupTestPinia()
@@ -62,51 +51,27 @@ describe('NodeSearchInput', () => {
function createWrapper(
props: Partial<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
searchQuery: string
filterQuery: string
}> = {}
) {
return mount(NodeSearchInput, {
props: {
filters: [],
activeFilter: null,
searchQuery: '',
filterQuery: '',
...props
},
global: { plugins: [testI18n] }
})
}
it('should route input to searchQuery when no active filter', async () => {
it('should route input to searchQuery', async () => {
const wrapper = createWrapper()
await wrapper.find('input').setValue('test search')
expect(wrapper.emitted('update:searchQuery')![0]).toEqual(['test search'])
})
it('should route input to filterQuery when active filter is set', async () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
await wrapper.find('input').setValue('IMAGE')
expect(wrapper.emitted('update:filterQuery')![0]).toEqual(['IMAGE'])
expect(wrapper.emitted('update:searchQuery')).toBeUndefined()
})
it('should show filter label placeholder when active filter is set', () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
expect(
(wrapper.find('input').element as HTMLInputElement).placeholder
).toContain('input')
})
it('should show add node placeholder when no active filter', () => {
it('should show add node placeholder', () => {
const wrapper = createWrapper()
expect(
@@ -114,16 +79,7 @@ describe('NodeSearchInput', () => {
).toContain('Add a node')
})
it('should hide filter chips when active filter is set', () => {
const wrapper = createWrapper({
filters: [createFilter('input', 'IMAGE')],
activeFilter: createActiveFilter('Input')
})
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(0)
})
it('should show filter chips when no active filter', () => {
it('should show filter chips when filters are present', () => {
const wrapper = createWrapper({
filters: [createFilter('input', 'IMAGE')]
})
@@ -131,16 +87,6 @@ describe('NodeSearchInput', () => {
expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(1)
})
it('should emit cancelFilter when cancel button is clicked', async () => {
const wrapper = createWrapper({
activeFilter: createActiveFilter('Input')
})
await wrapper.find('[data-testid="cancel-filter"]').trigger('click')
expect(wrapper.emitted('cancelFilter')).toHaveLength(1)
})
it('should emit selectCurrent on Enter', async () => {
const wrapper = createWrapper()

View File

@@ -7,61 +7,41 @@
@remove-tag="onRemoveTag"
@click="inputRef?.focus()"
>
<!-- Active filter label (filter selection mode) -->
<span
v-if="activeFilter"
class="text-foreground -my-1 inline-flex shrink-0 items-center gap-1 rounded-lg bg-base-background px-2 py-1 text-sm opacity-80"
>
{{ activeFilter.label }}:
<button
type="button"
data-testid="cancel-filter"
class="aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
:aria-label="$t('g.remove')"
@click="emit('cancelFilter')"
>
<i class="pi pi-times text-xs" />
</button>
</span>
<!-- Applied filter chips -->
<template v-if="!activeFilter">
<TagsInputItem
v-for="filter in filters"
:key="filterKey(filter)"
:value="filterKey(filter)"
data-testid="filter-chip"
class="-my-1 inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
<TagsInputItem
v-for="filter in filters"
:key="filterKey(filter)"
:value="filterKey(filter)"
data-testid="filter-chip"
class="-my-1 inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
>
<span class="text-sm opacity-80">
{{ t(`g.${filter.filterDef.id}`) }}:
</span>
<span :style="{ color: getLinkTypeColor(filter.value) }"> &bull; </span>
<span class="text-sm">{{ filter.value }}</span>
<TagsInputItemDelete
as="button"
type="button"
data-testid="chip-delete"
:aria-label="$t('g.remove')"
class="ml-1 flex aspect-square cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
>
<span class="text-sm opacity-80">
{{ t(`g.${filter.filterDef.id}`) }}:
</span>
<span :style="{ color: getLinkTypeColor(filter.value) }">
&bull;
</span>
<span class="text-sm">{{ filter.value }}</span>
<TagsInputItemDelete
as="button"
type="button"
data-testid="chip-delete"
:aria-label="$t('g.remove')"
class="ml-1 aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
>
<i class="pi pi-times text-xs" />
</TagsInputItemDelete>
</TagsInputItem>
</template>
<i class="icon-[lucide--x] size-3" />
</TagsInputItemDelete>
</TagsInputItem>
<TagsInputInput as-child>
<input
ref="inputRef"
v-model="inputValue"
v-model="searchQuery"
type="text"
role="combobox"
aria-autocomplete="list"
:aria-expanded="true"
:aria-controls="activeFilter ? 'filter-options-list' : 'results-list'"
:aria-label="inputPlaceholder"
:placeholder="inputPlaceholder"
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
aria-controls="results-list"
:aria-label="t('g.addNode')"
:placeholder="t('g.addNode')"
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
@keydown.enter.prevent="emit('selectCurrent')"
@keydown.down.prevent="emit('navigateDown')"
@keydown.up.prevent="emit('navigateUp')"
@@ -81,22 +61,18 @@ import {
TagsInputRoot
} from 'reka-ui'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
const { filters, activeFilter } = defineProps<{
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
}>()
const searchQuery = defineModel<string>('searchQuery', { required: true })
const filterQuery = defineModel<string>('filterQuery', { required: true })
const emit = defineEmits<{
removeFilter: [filter: FuseFilterWithValue<ComfyNodeDefImpl, string>]
cancelFilter: []
navigateDown: []
navigateUp: []
selectCurrent: []
@@ -105,23 +81,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const inputRef = ref<HTMLInputElement>()
const inputValue = computed({
get: () => (activeFilter ? filterQuery.value : searchQuery.value),
set: (value: string) => {
if (activeFilter) {
filterQuery.value = value
} else {
searchQuery.value = value
}
}
})
const inputPlaceholder = computed(() =>
activeFilter
? t('g.filterByType', { type: activeFilter.label.toLowerCase() })
: t('g.addNode')
)
const tagValues = computed(() => filters.map(filterKey))
function filterKey(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {

View File

@@ -2,46 +2,78 @@
<div
class="option-container flex w-full cursor-pointer items-center justify-between overflow-hidden"
>
<div class="flex flex-col gap-0.5 overflow-hidden">
<div class="text-foreground flex items-center gap-2 font-semibold">
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<!-- Row 1: Name (left) + badges (right) -->
<div class="text-foreground flex items-center gap-2 text-sm">
<span v-if="isBookmarked && !hideBookmarkIcon">
<i class="pi pi-bookmark-fill mr-1 text-sm" />
</span>
<span v-html="highlightQuery(nodeDef.display_name, currentQuery)" />
<span v-if="showIdName">&nbsp;</span>
<span
class="truncate"
v-html="highlightQuery(nodeDef.display_name, currentQuery)"
/>
<span
v-if="showIdName"
class="rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
data-testid="node-id-badge"
class="shrink-0 rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
v-html="highlightQuery(nodeDef.name, currentQuery)"
/>
<NodePricingBadge :node-def="nodeDef" />
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
<template v-if="showDescription">
<div class="flex-1" />
<div class="flex shrink-0 items-center gap-1">
<span
v-if="showSourceBadge && !isCustom"
aria-hidden="true"
class="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-secondary-background-hover/80"
>
<ComfyLogo :size="10" mode="fill" color="currentColor" />
</span>
<span
v-else-if="showSourceBadge && isCustom"
:class="badgePillClass"
>
<span class="truncate text-[10px]">
{{ nodeDef.nodeSource.displayText }}
</span>
</span>
<span
v-if="nodeDef.api_node && providerName"
:class="badgePillClass"
>
<i
aria-hidden="true"
class="icon-[lucide--component] size-3 text-amber-400"
/>
<i
aria-hidden="true"
:class="cn(getProviderIcon(providerName), 'size-3')"
/>
</span>
</div>
</template>
<template v-else>
<NodePricingBadge :node-def="nodeDef" />
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
</template>
</div>
<div
v-if="showDescription"
class="flex items-center gap-1 text-[11px] text-muted-foreground"
class="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
>
<span
v-if="
showSourceBadge &&
nodeDef.nodeSource.type !== NodeSourceType.Core &&
nodeDef.nodeSource.type !== NodeSourceType.Unknown
"
class="border-border mr-0.5 inline-flex shrink-0 rounded-sm border bg-base-foreground/5 px-1.5 py-0.5 text-xs text-base-foreground/70"
>
{{ nodeDef.nodeSource.displayText }}
<span v-if="showCategory" class="max-w-2/5 shrink-0 truncate">
{{ nodeDef.category.replaceAll('/', ' / ') }}
</span>
<TextTicker v-if="nodeDef.description">
<span
v-if="nodeDef.description && showCategory"
class="h-3 w-px shrink-0 bg-border-default"
/>
<TextTicker v-if="nodeDef.description" class="min-w-0 flex-1">
{{ nodeDef.description }}
</TextTicker>
</div>
<div
v-else-if="showCategory"
class="option-category truncate text-sm font-light text-muted"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
</div>
</div>
<div v-if="!showDescription" class="flex items-center gap-1">
<span
@@ -82,14 +114,20 @@
import { computed } from 'vue'
import TextTicker from '@/components/common/TextTicker.vue'
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
import NodePricingBadge from '@/components/node/NodePricingBadge.vue'
import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import {
isCustomNode as isCustomNodeDef,
NodeSourceType
} from '@/types/nodeSource'
import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const {
nodeDef,
@@ -105,6 +143,9 @@ const {
hideBookmarkIcon?: boolean
}>()
const badgePillClass =
'flex h-[18px] max-w-28 shrink-0 items-center justify-center gap-1 rounded-full bg-secondary-background-hover/80 px-2'
const settingStore = useSettingStore()
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
@@ -122,4 +163,6 @@ const nodeFrequency = computed(() =>
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef))
const providerName = computed(() => getProviderName(nodeDef.category))
const isCustom = computed(() => isCustomNodeDef(nodeDef))
</script>

View File

@@ -0,0 +1,168 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { testI18n } from '@/components/searchbox/v2/__test__/testUtils'
function createMockChip(
data: string[] = ['IMAGE', 'LATENT', 'MODEL']
): FilterChip {
return {
key: 'input',
label: 'Input',
filter: {
id: 'input',
matches: vi.fn(),
fuseSearch: {
search: vi.fn((query: string) =>
data.filter((d) => d.toLowerCase().includes(query.toLowerCase()))
),
data
}
} as unknown as FilterChip['filter']
}
}
describe(NodeSearchTypeFilterPopover, () => {
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
wrapper?.unmount()
})
function createWrapper(
props: {
chip?: FilterChip
selectedValues?: string[]
} = {}
) {
wrapper = mount(NodeSearchTypeFilterPopover, {
props: {
chip: props.chip ?? createMockChip(),
selectedValues: props.selectedValues ?? []
},
slots: {
default: '<button data-testid="trigger">Input</button>'
},
global: {
plugins: [testI18n]
},
attachTo: document.body
})
return wrapper
}
async function openPopover(w: ReturnType<typeof mount>) {
await w.find('[data-testid="trigger"]').trigger('click')
await nextTick()
await nextTick()
}
function getOptions() {
return wrapper.findAll('[role="option"]')
}
it('should render the trigger slot', () => {
createWrapper()
expect(wrapper.find('[data-testid="trigger"]').exists()).toBe(true)
})
it('should show popover content when trigger is clicked', async () => {
createWrapper()
await openPopover(wrapper)
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
})
it('should display all options sorted alphabetically', async () => {
createWrapper({ chip: createMockChip(['MODEL', 'IMAGE', 'LATENT']) })
await openPopover(wrapper)
const options = getOptions()
expect(options).toHaveLength(3)
const texts = options.map((o) => o.text().trim())
expect(texts[0]).toContain('IMAGE')
expect(texts[1]).toContain('LATENT')
expect(texts[2]).toContain('MODEL')
})
it('should show selected count text', async () => {
createWrapper({ selectedValues: ['IMAGE', 'LATENT'] })
await openPopover(wrapper)
expect(wrapper.text()).toContain('2 items selected')
})
it('should show clear all button only when values are selected', async () => {
createWrapper({ selectedValues: [] })
await openPopover(wrapper)
const buttons = wrapper.findAll('button')
const clearBtn = buttons.find((b) => b.text().includes('Clear all'))
expect(clearBtn).toBeUndefined()
})
it('should show clear all button when values are selected', async () => {
createWrapper({ selectedValues: ['IMAGE'] })
await openPopover(wrapper)
const buttons = wrapper.findAll('button')
const clearBtn = buttons.find((b) => b.text().includes('Clear all'))
expect(clearBtn).toBeTruthy()
})
it('should emit clear when clear all button is clicked', async () => {
createWrapper({ selectedValues: ['IMAGE'] })
await openPopover(wrapper)
const clearBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Clear all'))!
await clearBtn.trigger('click')
await nextTick()
expect(wrapper.emitted('clear')).toHaveLength(1)
})
it('should emit toggle when an option is clicked', async () => {
createWrapper()
await openPopover(wrapper)
const options = getOptions()
await options[0].trigger('click')
await nextTick()
expect(wrapper.emitted('toggle')).toBeTruthy()
expect(wrapper.emitted('toggle')![0][0]).toBe('IMAGE')
})
it('should filter options via search input', async () => {
createWrapper()
await openPopover(wrapper)
const searchInput = wrapper.find('input')
await searchInput.setValue('IMAGE')
await nextTick()
const options = getOptions()
expect(options).toHaveLength(1)
expect(options[0].text()).toContain('IMAGE')
})
it('should show no results when search matches nothing', async () => {
createWrapper()
await openPopover(wrapper)
const searchInput = wrapper.find('input')
await searchInput.setValue('NONEXISTENT')
await nextTick()
expect(getOptions()).toHaveLength(0)
expect(wrapper.text()).toContain('No results')
})
})

View File

@@ -0,0 +1,175 @@
<template>
<PopoverRoot v-model:open="open" @update:open="onOpenChange">
<PopoverTrigger as-child>
<slot />
</PopoverTrigger>
<PopoverContent
side="bottom"
:side-offset="4"
:collision-padding="10"
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-64 rounded-lg border border-border-default bg-base-background px-4 py-1 shadow-interface will-change-[transform,opacity]"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
@escape-key-down.prevent
@keydown.escape.stop="closeWithEscape"
>
<ListboxRoot
multiple
selection-behavior="toggle"
:model-value="selectedValues"
@update:model-value="onSelectionChange"
>
<div
class="mt-2 flex h-8 items-center gap-2 rounded-sm border border-border-default px-2"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
/>
<ListboxFilter
ref="searchFilterRef"
v-model="searchQuery"
:placeholder="t('g.search')"
class="text-foreground size-full border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div class="flex items-center justify-between py-3">
<span class="text-sm text-muted-foreground">
{{
t(
'g.itemsSelected',
{ count: selectedValues.length },
selectedValues.length
)
}}
</span>
<button
v-if="selectedValues.length > 0"
type="button"
class="cursor-pointer border-none bg-transparent font-inter text-sm text-base-foreground"
@click="emit('clear')"
>
{{ t('g.clearAll') }}
</button>
</div>
<div class="h-px bg-border-default" />
<ListboxContent class="max-h-64 overflow-y-auto py-3">
<ListboxItem
v-for="option in filteredOptions"
:key="option"
:value="option"
data-testid="filter-option"
class="text-foreground flex cursor-pointer items-center gap-2 rounded-sm px-1 py-2 text-sm outline-none data-highlighted:bg-secondary-background-hover"
>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-default',
selectedSet.has(option) &&
'text-primary-foreground border-primary bg-primary'
)
"
>
<i
v-if="selectedSet.has(option)"
class="icon-[lucide--check] size-3"
/>
</span>
<span class="truncate">{{ option }}</span>
<span
class="mr-1 ml-auto text-lg leading-none"
:style="{ color: getLinkTypeColor(option) }"
>
&bull;
</span>
</ListboxItem>
<div
v-if="filteredOptions.length === 0"
class="px-1 py-4 text-center text-sm text-muted-foreground"
>
{{ t('g.noResults') }}
</div>
</ListboxContent>
</ListboxRoot>
</PopoverContent>
</PopoverRoot>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AcceptableValue } from 'reka-ui'
import {
ListboxContent,
ListboxFilter,
ListboxItem,
ListboxRoot,
PopoverContent,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const { chip, selectedValues } = defineProps<{
chip: FilterChip
selectedValues: string[]
}>()
const emit = defineEmits<{
toggle: [value: string]
clear: []
escapeClose: []
}>()
const { t } = useI18n()
const open = ref(false)
const closedWithEscape = ref(false)
const searchQuery = ref('')
const searchFilterRef = ref<InstanceType<typeof ListboxFilter>>()
function onOpenChange(isOpen: boolean) {
if (!isOpen) searchQuery.value = ''
}
const selectedSet = computed(() => new Set(selectedValues))
function onSelectionChange(value: AcceptableValue) {
const newValues = value as string[]
const added = newValues.find((v) => !selectedSet.value.has(v))
const removed = selectedValues.find((v) => !newValues.includes(v))
const toggled = added ?? removed
if (toggled) emit('toggle', toggled)
}
const filteredOptions = computed(() => {
const { fuseSearch } = chip.filter
if (searchQuery.value) {
return fuseSearch.search(searchQuery.value).slice(0, 64)
}
return fuseSearch.data.slice().sort()
})
function closeWithEscape() {
closedWithEscape.value = true
open.value = false
}
function onOpenAutoFocus(event: Event) {
event.preventDefault()
const el = searchFilterRef.value?.$el as HTMLInputElement | undefined
el?.focus()
}
function onCloseAutoFocus(event: Event) {
if (closedWithEscape.value) {
event.preventDefault()
closedWithEscape.value = false
emit('escapeClose')
}
}
</script>

View File

@@ -39,7 +39,9 @@ export const testI18n = createI18n({
mostRelevant: 'Most relevant',
recents: 'Recents',
favorites: 'Favorites',
bookmarked: 'Bookmarked',
essentials: 'Essentials',
category: 'Category',
custom: 'Custom',
comfy: 'Comfy',
partner: 'Partner',
@@ -49,15 +51,13 @@ export const testI18n = createI18n({
input: 'Input',
output: 'Output',
source: 'Source',
search: 'Search'
},
sideToolbar: {
nodeLibraryTab: {
filterOptions: {
blueprints: 'Blueprints',
partnerNodes: 'Partner Nodes'
}
}
search: 'Search',
blueprints: 'Blueprints',
partnerNodes: 'Partner Nodes',
remove: 'Remove',
itemsSelected:
'No items selected | {count} item selected | {count} items selected',
clearAll: 'Clear all'
}
}
}

View File

@@ -1,94 +0,0 @@
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCopy = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@vueuse/core', () => ({
useClipboard: vi.fn(() => ({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true)
}))
}))
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import { useClipboard } from '@vueuse/core'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
describe('useCopyToClipboard', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true),
text: ref('')
})
})
it('shows success toast when modern clipboard succeeds', async () => {
mockCopy.mockResolvedValue(undefined)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).toHaveBeenCalledWith('hello')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('falls back to legacy when modern clipboard fails', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('shows error toast when both modern and legacy fail', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => false)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('falls through to legacy when isSupported is false', async () => {
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => false),
text: ref('')
})
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).not.toHaveBeenCalled()
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
})

View File

@@ -3,60 +3,34 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
function legacyCopy(text: string): boolean {
const textarea = document.createElement('textarea')
textarea.setAttribute('readonly', '')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
return document.execCommand('copy')
} finally {
textarea.remove()
}
}
export function useCopyToClipboard() {
const { copy, isSupported } = useClipboard()
const { copy, copied } = useClipboard({ legacy: true })
const toast = useToast()
async function copyToClipboard(text: string) {
let success = false
try {
if (isSupported.value) {
await copy(text)
success = true
await copy(text)
if (copied.value) {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
} else {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
} catch {
// Modern clipboard API failed, fall through to legacy
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
if (!success) {
try {
success = legacyCopy(text)
} catch {
// Legacy also failed
}
}
toast.add(
success
? {
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
}
: {
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
}
)
}
return {

View File

@@ -751,7 +751,7 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('full linked coverage prunes promotions referencing non-existent nodes', () => {
test('full linked coverage does not prune unresolved independent fallback promotions', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'widgetA', type: '*' }]
})
@@ -776,9 +776,9 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.rootGraph.id,
subgraphNode.id
)
// Node 9999 does not exist in the subgraph, so its entry is pruned
expect(promotions).toStrictEqual([
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' }
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
{ sourceNodeId: '9999', sourceWidgetName: 'widgetA' }
])
})

View File

@@ -1,287 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
ExportedSubgraphInstance,
Positionable,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
/**
* Registers a minimal SubgraphNode class for a subgraph definition
* so that `LiteGraph.createNode(subgraphId)` works in tests.
*/
function registerSubgraphNodeType(subgraph: Subgraph): void {
const instanceData: ExportedSubgraphInstance = {
id: -1,
type: subgraph.id,
pos: [0, 0],
size: [100, 100],
inputs: [],
outputs: [],
flags: {},
order: 0,
mode: 0
}
const node = class extends SubgraphNode {
constructor() {
super(subgraph.rootGraph, subgraph, instanceData)
}
}
Object.defineProperty(node, 'title', { value: subgraph.name })
LiteGraph.registerNodeType(subgraph.id, node)
}
const registeredTypes: string[] = []
afterEach(() => {
for (const type of registeredTypes) {
LiteGraph.unregisterNodeType(type)
}
registeredTypes.length = 0
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('_repointAncestorPromotions', () => {
function setupParentSubgraphWithWidgets() {
const parentSubgraph = createTestSubgraph({
name: 'Parent Subgraph',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const rootGraph = parentSubgraph.rootGraph
// We need to listen for new subgraph registrations so
// LiteGraph.createNode works during convertToSubgraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const interiorNode = new LGraphNode('Interior Node')
interiorNode.addInput('in', '*')
interiorNode.addOutput('out', '*')
interiorNode.addWidget('text', 'prompt', 'hello world', () => {})
parentSubgraph.add(interiorNode)
// Create host SubgraphNode in root graph
registerSubgraphNodeType(parentSubgraph)
registeredTypes.push(parentSubgraph.id)
const hostNode = createTestSubgraphNode(parentSubgraph)
rootGraph.add(hostNode)
return { rootGraph, parentSubgraph, interiorNode, hostNode }
}
it('repoints parent promotions when interior nodes are packed into a nested subgraph', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
// Promote the interior node's widget on the host
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
const beforeEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(beforeEntries).toHaveLength(1)
expect(beforeEntries[0].sourceNodeId).toBe(String(interiorNode.id))
// Pack the interior node into a nested subgraph
const { node: nestedSubgraphNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
// After conversion, the host's promotion should be repointed
const afterEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterEntries).toHaveLength(1)
expect(afterEntries[0].sourceNodeId).toBe(String(nestedSubgraphNode.id))
expect(afterEntries[0].sourceWidgetName).toBe('prompt')
expect(afterEntries[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
// The nested subgraph node should also have the promotion
const nestedEntries = store.getPromotions(
rootGraph.id,
nestedSubgraphNode.id
)
expect(nestedEntries).toHaveLength(1)
expect(nestedEntries[0].sourceNodeId).toBe(String(interiorNode.id))
expect(nestedEntries[0].sourceWidgetName).toBe('prompt')
})
it('preserves promotions that reference non-moved nodes', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
const remainingNode = new LGraphNode('Remaining Node')
remainingNode.addWidget('text', 'widget_b', 'b', () => {})
parentSubgraph.add(remainingNode)
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(remainingNode.id),
sourceWidgetName: 'widget_b'
})
// Pack only the interiorNode
parentSubgraph.convertToSubgraph(new Set<Positionable>([interiorNode]))
const afterEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterEntries).toHaveLength(2)
// The remaining node's promotion should be unchanged
const remainingEntry = afterEntries.find(
(e) => e.sourceWidgetName === 'widget_b'
)
expect(remainingEntry?.sourceNodeId).toBe(String(remainingNode.id))
expect(remainingEntry?.disambiguatingSourceNodeId).toBeUndefined()
// The moved node's promotion should be repointed
const movedEntry = afterEntries.find((e) => e.sourceWidgetName === 'prompt')
expect(movedEntry?.sourceNodeId).not.toBe(String(interiorNode.id))
expect(movedEntry?.disambiguatingSourceNodeId).toBe(String(interiorNode.id))
})
it('does not modify promotions when converting in root graph', () => {
const parentSubgraph = createTestSubgraph({ name: 'Dummy' })
const rootGraph = parentSubgraph.rootGraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const node = new LGraphNode('Root Node')
node.addInput('in', '*')
node.addOutput('out', '*')
node.addWidget('text', 'value', 'test', () => {})
rootGraph.add(node)
// Converting in root graph should not throw
rootGraph.convertToSubgraph(new Set<Positionable>([node]))
})
it('uses existing disambiguatingSourceNodeId as fallback on repeat packing', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
// First pack: interior node → nested subgraph
const { node: firstNestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
const afterFirstPack = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterFirstPack).toHaveLength(1)
expect(afterFirstPack[0].sourceNodeId).toBe(String(firstNestedNode.id))
expect(afterFirstPack[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
// Second pack: nested subgraph → another level of nesting
const { node: secondNestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([firstNestedNode])
)
// After second pack, promotion should use the disambiguatingSourceNodeId
// as fallback and point to the new nested node
const afterSecondPack = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterSecondPack).toHaveLength(1)
expect(afterSecondPack[0].sourceNodeId).toBe(String(secondNestedNode.id))
expect(afterSecondPack[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
})
it('repoints promotions for multiple host instances of the same subgraph', () => {
const parentSubgraph = createTestSubgraph({
name: 'Shared Parent Subgraph',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const rootGraph = parentSubgraph.rootGraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const interiorNode = new LGraphNode('Interior Node')
interiorNode.addInput('in', '*')
interiorNode.addOutput('out', '*')
interiorNode.addWidget('text', 'prompt', 'shared', () => {})
parentSubgraph.add(interiorNode)
// Create TWO host SubgraphNodes pointing to the same subgraph
registerSubgraphNodeType(parentSubgraph)
registeredTypes.push(parentSubgraph.id)
const hostNode1 = createTestSubgraphNode(parentSubgraph)
const hostNode2 = createTestSubgraphNode(parentSubgraph)
rootGraph.add(hostNode1)
rootGraph.add(hostNode2)
// Promote on both hosts
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode1.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
store.promote(rootGraph.id, hostNode2.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
// Pack the interior node
const { node: nestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
// Both hosts' promotions should be repointed to the nested node
const host1Promotions = store.getPromotions(rootGraph.id, hostNode1.id)
expect(host1Promotions).toHaveLength(1)
expect(host1Promotions[0].sourceNodeId).toBe(String(nestedNode.id))
expect(host1Promotions[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
const host2Promotions = store.getPromotions(rootGraph.id, hostNode2.id)
expect(host2Promotions).toHaveLength(1)
expect(host2Promotions[0].sourceNodeId).toBe(String(nestedNode.id))
expect(host2Promotions[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
})
})

View File

@@ -1,6 +1,5 @@
import { toString } from 'es-toolkit/compat'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
@@ -10,10 +9,7 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -1911,13 +1907,6 @@ export class LGraph
subgraphNode._setConcreteSlots()
subgraphNode.arrange()
// Repair ancestor promotions: when nodes are packed into a nested
// subgraph, any host SubgraphNode whose proxyWidgets referenced the
// moved nodes must be repointed to chain through the new nested node.
if (!this.isRootGraph) {
this._repointAncestorPromotions(nodes, subgraphNode as SubgraphNode)
}
this.canvasAction((c) =>
c.canvas.dispatchEvent(
new CustomEvent('subgraph-converted', {
@@ -1930,75 +1919,6 @@ export class LGraph
return { subgraph, node: subgraphNode as SubgraphNode }
}
/**
* After packing nodes into a nested subgraph, repoint any ancestor
* SubgraphNode promotions that referenced the moved nodes so they
* chain through the newly created nested SubgraphNode.
*/
private _repointAncestorPromotions(
movedNodes: Set<LGraphNode>,
nestedSubgraphNode: SubgraphNode
): void {
const movedNodeIds = new Set([...movedNodes].map((n) => String(n.id)))
const store = usePromotionStore()
const nestedNodeId = String(nestedSubgraphNode.id)
const graphId = this.rootGraph.id
const nestedEntries = store.getPromotions(graphId, nestedSubgraphNode.id)
const nextNestedEntries = [...nestedEntries]
const nestedEntryKeys = new Set(
nestedEntries.map((entry) => makePromotionEntryKey(entry))
)
const hostUpdates: Array<{
node: SubgraphNode
entries: PromotedWidgetSource[]
}> = []
// Find all SubgraphNode instances that host `this` subgraph.
// They live in any graph and have `type === this.id`.
const allGraphs: LGraph[] = [
this.rootGraph,
...this.rootGraph._subgraphs.values()
]
for (const graph of allGraphs) {
for (const node of graph._nodes) {
if (!node.isSubgraphNode() || node.type !== this.id) continue
const entries = store.getPromotions(graphId, node.id)
const movedEntries = entries.filter((entry) =>
movedNodeIds.has(entry.sourceNodeId)
)
if (movedEntries.length === 0) continue
for (const entry of movedEntries) {
const key = makePromotionEntryKey(entry)
if (nestedEntryKeys.has(key)) continue
nestedEntryKeys.add(key)
nextNestedEntries.push(entry)
}
const nextEntries = entries.map((entry) => {
if (!movedNodeIds.has(entry.sourceNodeId)) return entry
return {
sourceNodeId: nestedNodeId,
sourceWidgetName: entry.sourceWidgetName,
disambiguatingSourceNodeId:
entry.disambiguatingSourceNodeId ?? entry.sourceNodeId
}
})
hostUpdates.push({ node, entries: nextEntries })
}
}
if (nextNestedEntries.length !== nestedEntries.length)
store.setPromotions(graphId, nestedSubgraphNode.id, nextNestedEntries)
for (const { node, entries } of hostUpdates) {
store.setPromotions(graphId, node.id, entries)
node.rebuildInputWidgetBindings()
}
}
unpackSubgraph(
subgraphNode: SubgraphNode,
options?: { skipMissingNodes?: boolean }

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