Compare commits
34 Commits
coderabbit
...
pysssss/no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
711371d9c5 | ||
|
|
c00e285768 | ||
|
|
8f41bc7527 | ||
|
|
11b62c48e3 | ||
|
|
cc3d3f1d25 | ||
|
|
92e65aaaa7 | ||
|
|
f82f8624e1 | ||
|
|
c46316d248 | ||
|
|
8e5dc15e5d | ||
|
|
da2fedebcf | ||
|
|
2a531ff80b | ||
|
|
b6234b96af | ||
|
|
bd66617d3f | ||
|
|
98eac41f07 | ||
|
|
307a1c77c0 | ||
|
|
bbd1e60f7b | ||
|
|
9100058fc1 | ||
|
|
04c00aadd8 | ||
|
|
2f1615c505 | ||
|
|
cf4dfceaee | ||
|
|
dbb70323bf | ||
|
|
6689510591 | ||
|
|
82e62694a9 | ||
|
|
d49f263536 | ||
|
|
d30bb01b4b | ||
|
|
320cd82f0d | ||
|
|
8a30211bea | ||
|
|
12fd0981a8 | ||
|
|
0772f2a7fe | ||
|
|
08666d8e81 | ||
|
|
d18243e085 | ||
|
|
3cba424e52 | ||
|
|
0f3b2e0455 | ||
|
|
fd31f9d0ed |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
4
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/pr-report.yaml
vendored
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
2
apps/website/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
dist/
|
||||
.astro/
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
apps/website/src/env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="astro/client" />
|
||||
@@ -1,2 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@comfyorg/design-system/css/base.css';
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "astro.config.mjs"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.`
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 77 KiB |
@@ -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)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>')
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
21
src/App.vue
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }"
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
28
src/components/node/CreditBadge.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) }"
|
||||
>•</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>
|
||||
|
||||
@@ -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) }"
|
||||
>•</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>
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) }"> • </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) }">
|
||||
•
|
||||
</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>) {
|
||||
|
||||
@@ -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"> </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>
|
||||
|
||||
168
src/components/searchbox/v2/NodeSearchTypeFilterPopover.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
175
src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue
Normal 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) }"
|
||||
>
|
||||
•
|
||||
</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>
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||