Compare commits
44 Commits
fix/empty-
...
fix/model-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb8873e05 | ||
|
|
6c7c3ea006 | ||
|
|
771f68f92a | ||
|
|
c5e9e52e5f | ||
|
|
fa1ffcba01 | ||
|
|
f1db1122f3 | ||
|
|
f56abb3ecf | ||
|
|
95c6811f59 | ||
|
|
88079250eb | ||
|
|
08ea013c51 | ||
|
|
4aae52c2fc | ||
|
|
6b7691422b | ||
|
|
437f41c553 | ||
|
|
975393b48b | ||
|
|
a44fa1fdd5 | ||
|
|
cc3acebceb | ||
|
|
23c22e4c52 | ||
|
|
88b54c6775 | ||
|
|
e40995fb6c | ||
|
|
908a3ea418 | ||
|
|
0ae4b78cbc | ||
|
|
5f14276159 | ||
|
|
df81c66725 | ||
|
|
c5ad6cf1aa | ||
|
|
fe1fae4de1 | ||
|
|
38aad02fb9 | ||
|
|
7c4234cf93 | ||
|
|
4bfc730a0e | ||
|
|
001916edf6 | ||
|
|
66daa6d645 | ||
|
|
32a53eeaee | ||
|
|
7c6ab19484 | ||
|
|
8f9fe3b21e | ||
|
|
4411d9a417 | ||
|
|
98d56bdada | ||
|
|
a52579dc2d | ||
|
|
b41e0009ae | ||
|
|
0049de780d | ||
|
|
df772c6201 | ||
|
|
a624caed6c | ||
|
|
013a50ce13 | ||
|
|
0d257c79fb | ||
|
|
cc4cca616e | ||
|
|
fae8f2024a |
@@ -18,12 +18,20 @@ 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 | Squash merge (`gh pr merge --squash --admin`) |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
| 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.
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
@@ -108,11 +116,15 @@ 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
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
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.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
|
||||
@@ -19,23 +19,44 @@ done
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs
|
||||
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
## Step 2: Review & Merge Clean Auto-PRs
|
||||
> **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:
|
||||
|
||||
```bash
|
||||
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
|
||||
# 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
|
||||
done
|
||||
```
|
||||
|
||||
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
@@ -63,6 +84,13 @@ 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
|
||||
@@ -82,7 +110,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
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
@@ -132,7 +160,8 @@ 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
|
||||
gh pr merge $PR --squash --admin
|
||||
# Wait for CI after rebase before merging
|
||||
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
@@ -146,5 +175,31 @@ 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` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
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.
|
||||
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 | Status | Backport PR | Notes |
|
||||
| ----- | ----- | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
| PR# | Title | CI Status | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
@@ -19,6 +19,7 @@ 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)
|
||||
```
|
||||
@@ -41,6 +42,11 @@ 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 |
|
||||
|
||||
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 -10); do
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); 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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import dotenv from 'dotenv'
|
||||
import { config as dotenvConfig } 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'
|
||||
|
||||
dotenv.config()
|
||||
dotenvConfig()
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
|
||||
2
apps/website/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
.astro/
|
||||
24
apps/website/astro.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
80
apps/website/package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/website/public/fonts/inter-latin-italic.woff2
Normal file
BIN
apps/website/public/fonts/inter-latin-normal.woff2
Normal file
1
apps/website/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
2
apps/website/src/styles/global.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@comfyorg/design-system/css/base.css';
|
||||
9
apps/website/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "astro.config.mjs"]
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
{
|
||||
"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 dotenv from 'dotenv'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
@@ -40,7 +40,7 @@ import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import type { NodeReference } from './utils/litegraphUtils'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenv.config()
|
||||
dotenvConfig()
|
||||
|
||||
class ComfyPropertiesPanel {
|
||||
readonly root: Locator
|
||||
|
||||
@@ -91,6 +91,12 @@ 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,13 +25,15 @@ export class DragDropHelper {
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
url,
|
||||
waitForUpload = false
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
@@ -43,7 +45,8 @@ export class DragDropHelper {
|
||||
fileType?: string
|
||||
buffer?: Uint8Array | number[]
|
||||
url?: string
|
||||
} = { dropPosition }
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = this.assetPath(fileName)
|
||||
@@ -115,15 +118,17 @@ export class DragDropHelper {
|
||||
)
|
||||
}
|
||||
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
if (!params.preserveNativePropagation) {
|
||||
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)
|
||||
@@ -154,7 +159,10 @@ export class DragDropHelper {
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: { dropPosition?: Position } = {}
|
||||
options: {
|
||||
dropPosition?: Position
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ url, ...options })
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface PerfMeasurement {
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
heapUsedBytes: number
|
||||
domNodes: number
|
||||
jsHeapTotalBytes: number
|
||||
scriptDurationMs: number
|
||||
@@ -190,6 +191,7 @@ 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,
|
||||
|
||||
@@ -28,10 +28,15 @@ export const TestIds = {
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
errorCardCopy: 'error-card-copy',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section'
|
||||
whatsNewSection: 'whats-new-section',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
@@ -57,6 +62,8 @@ export const TestIds = {
|
||||
colorRed: 'red'
|
||||
},
|
||||
widgets: {
|
||||
container: 'node-widgets',
|
||||
widget: 'node-widget',
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
@@ -76,6 +83,10 @@ export const TestIds = {
|
||||
},
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -101,3 +112,4 @@ export type TestIdValue =
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { backupPath } from './utils/backupUtils'
|
||||
|
||||
dotenv.config()
|
||||
dotenvConfig()
|
||||
|
||||
export default function globalSetup(_config: FullConfig) {
|
||||
export default function globalSetup() {
|
||||
if (!process.env.CI) {
|
||||
if (process.env.TEST_COMFYUI_DIR) {
|
||||
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { writePerfReport } from './helpers/perfReporter'
|
||||
import { restorePath } from './utils/backupUtils'
|
||||
|
||||
dotenv.config()
|
||||
dotenvConfig()
|
||||
|
||||
export default function globalTeardown(_config: FullConfig) {
|
||||
export default function globalTeardown() {
|
||||
writePerfReport()
|
||||
|
||||
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
|
||||
|
||||
@@ -38,16 +38,13 @@ 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',
|
||||
@@ -102,16 +99,13 @@ 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',
|
||||
|
||||
318
browser_tests/tests/defaultKeybindings.spec.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
|
||||
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -42,11 +42,13 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
|
||||
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the errors tab and verify subgraph node content
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
@@ -75,7 +77,9 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Click "See Errors" to open the right side panel errors tab
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify MissingNodeCard is rendered in the errors tab
|
||||
@@ -165,17 +169,19 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Verify Find on GitHub button is present in the error card
|
||||
const findOnGithubButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Find on GitHub'
|
||||
})
|
||||
const findOnGithubButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorCardFindOnGithub
|
||||
)
|
||||
await expect(findOnGithubButton).toBeVisible()
|
||||
|
||||
// Verify Copy button is present in the error card
|
||||
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
|
||||
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
|
||||
await expect(copyButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -204,7 +210,7 @@ test.describe('Missing models in Error Tab', () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -220,7 +226,7 @@ test.describe('Missing models in Error Tab', () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -231,13 +237,10 @@ test.describe('Missing models in Error Tab', () => {
|
||||
'missing/model_metadata_widget_mismatch'
|
||||
)
|
||||
|
||||
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).not.toBeVisible()
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
|
||||
@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
const generateUniqueFilename = (extension = '') =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
|
||||
|
||||
test.describe('Restore all open workflows on reload', () => {
|
||||
let workflowA: string
|
||||
let workflowB: string
|
||||
|
||||
const generateUniqueFilename = (extension = '') =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
@@ -829,6 +829,82 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Restore workflow tabs after browser restart', () => {
|
||||
let workflowA: string
|
||||
let workflowB: string
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
workflowA = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowA)
|
||||
workflowB = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowB)
|
||||
|
||||
// Wait for localStorage fallback pointers to be written
|
||||
await comfyPage.page.waitForFunction(() => {
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Simulate browser restart: clear sessionStorage (lost on close)
|
||||
// but keep localStorage (survives browser restart)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
})
|
||||
|
||||
test('Restores topbar workflow tabs after browser restart', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
// Wait for both restored tabs to render (localStorage fallback is async)
|
||||
await expect(
|
||||
comfyPage.page.locator('.workflow-tabs .workflow-label', {
|
||||
hasText: workflowA
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const tabs = await comfyPage.menu.topbar.getTabNames()
|
||||
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
|
||||
|
||||
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
|
||||
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
|
||||
test('Restores sidebar workflows after browser restart', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
const openWorkflows =
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
const activeWorkflowName =
|
||||
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
|
||||
expect(openWorkflows).toEqual(
|
||||
expect.arrayContaining([workflowA, workflowB])
|
||||
)
|
||||
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
|
||||
openWorkflows.indexOf(workflowB)
|
||||
)
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
})
|
||||
|
||||
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.EnableWorkflowViewRestore',
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 72 KiB |
@@ -67,5 +67,44 @@ test.describe(
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('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)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
51
browser_tests/tests/subgraphNestedStaleProxyWidgets.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
)
|
||||
132
browser_tests/tests/subgraphSlotAlignment.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -206,6 +206,31 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
await expect(nav).toBeVisible() // Nav should be visible at tablet size
|
||||
})
|
||||
|
||||
test(
|
||||
'select components in filter bar render correctly',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Wait for filter bar select components to render
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
|
||||
await expect(sortBySelect).toBeVisible()
|
||||
|
||||
// Screenshot the filter bar containing MultiSelect and SingleSelect
|
||||
const filterBar = sortBySelect.locator(
|
||||
'xpath=ancestor::div[contains(@class, "justify-between")]'
|
||||
)
|
||||
await expect(filterBar).toHaveScreenshot(
|
||||
'template-filter-bar-select-components.png',
|
||||
{
|
||||
mask: [comfyPage.page.locator('.p-toast')]
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'template cards descriptions adjust height dynamically',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
@@ -47,6 +47,46 @@ test.describe('Vue Node Moving', () => {
|
||||
}
|
||||
)
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
|
||||
steps: 5
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
|
||||
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
|
||||
|
||||
// The small movement should have selected the node, not dragged it
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('should move node when pointer moves beyond drag threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move 50px — well beyond the 3px drag threshold
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../../fixtures/selectors'
|
||||
|
||||
test.describe('Vue Upload Widgets', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -19,10 +20,14 @@ test.describe('Vue Upload Widgets', () => {
|
||||
).not.toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading image').count())
|
||||
.poll(() =>
|
||||
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.getByText('Error loading video').count())
|
||||
.poll(() =>
|
||||
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,4 +46,16 @@ 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,6 +27,17 @@ 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.3",
|
||||
"version": "1.43.5",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
46
packages/design-system/src/css/base.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
1665
pnpm-lock.yaml
generated
@@ -4,6 +4,7 @@ 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
|
||||
@@ -50,6 +51,7 @@ 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
|
||||
@@ -58,6 +60,7 @@ 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,6 +22,7 @@ interface PerfMeasurement {
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
heapUsedBytes: number
|
||||
domNodes: number
|
||||
jsHeapTotalBytes: number
|
||||
scriptDurationMs: number
|
||||
@@ -43,22 +44,46 @@ const HISTORY_DIR = 'temp/perf-history'
|
||||
|
||||
type MetricKey =
|
||||
| 'styleRecalcs'
|
||||
| 'styleRecalcDurationMs'
|
||||
| 'layouts'
|
||||
| 'layoutDurationMs'
|
||||
| 'taskDurationMs'
|
||||
| 'domNodes'
|
||||
| 'scriptDurationMs'
|
||||
| 'eventListeners'
|
||||
| 'totalBlockingTimeMs'
|
||||
| 'frameDurationMs'
|
||||
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
|
||||
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
|
||||
{ key: 'layouts', label: 'layouts', unit: '' },
|
||||
| '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
|
||||
},
|
||||
{ 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: '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 }
|
||||
]
|
||||
|
||||
function groupByName(
|
||||
@@ -134,7 +159,9 @@ function computeCV(stats: MetricStats): number {
|
||||
}
|
||||
|
||||
function formatValue(value: number, unit: string): string {
|
||||
return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}`
|
||||
if (unit === 'ms') return `${value.toFixed(0)}ms`
|
||||
if (unit === 'bytes') return formatBytes(value)
|
||||
return `${value.toFixed(0)}`
|
||||
}
|
||||
|
||||
function formatDelta(pct: number | null): string {
|
||||
@@ -159,6 +186,21 @@ 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`
|
||||
@@ -173,7 +215,7 @@ function renderFullReport(
|
||||
const lines: string[] = []
|
||||
const baselineGroups = groupByName(baseline.measurements)
|
||||
const tableHeader = [
|
||||
'| Metric | Baseline | PR (n=3) | Δ | Sig |',
|
||||
'| Metric | Baseline | PR (median) | Δ | Sig |',
|
||||
'|--------|----------|----------|---|-----|'
|
||||
]
|
||||
|
||||
@@ -183,36 +225,38 @@ function renderFullReport(
|
||||
for (const [testName, prSamples] of prGroups) {
|
||||
const baseSamples = baselineGroups.get(testName)
|
||||
|
||||
for (const { key, label, unit } of REPORTED_METRICS) {
|
||||
const prMean = meanMetric(prSamples, key)
|
||||
if (prMean === null) continue
|
||||
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
|
||||
const histStats = getHistoricalStats(historical, testName, key)
|
||||
const cv = computeCV(histStats)
|
||||
|
||||
if (!baseSamples?.length) {
|
||||
allRows.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const baseVal = meanMetric(baseSamples, key)
|
||||
const baseVal = medianMetric(baseSamples, key)
|
||||
if (baseVal === null) {
|
||||
allRows.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
const absDelta = prVal - baseVal
|
||||
const deltaPct =
|
||||
baseVal === 0
|
||||
? prMean === 0
|
||||
? prVal === 0
|
||||
? 0
|
||||
: null
|
||||
: ((prMean - baseVal) / baseVal) * 100
|
||||
const z = zScore(prMean, histStats)
|
||||
const sig = classifyChange(z, cv)
|
||||
: ((prVal - baseVal) / baseVal) * 100
|
||||
const z = zScore(prVal, histStats)
|
||||
const sig = classifyChange(z, cv, absDelta, minAbsDelta)
|
||||
|
||||
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
|
||||
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
|
||||
allRows.push(row)
|
||||
if (isNoteworthy(sig)) {
|
||||
flaggedRows.push(row)
|
||||
@@ -299,7 +343,7 @@ function renderColdStartReport(
|
||||
const lines: string[] = []
|
||||
const baselineGroups = groupByName(baseline.measurements)
|
||||
lines.push(
|
||||
`> ℹ️ Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`,
|
||||
`> ℹ️ Collecting baseline variance data (${historicalCount}/15 runs). Significance will appear after 2 main branch runs.`,
|
||||
'',
|
||||
'| Metric | Baseline | PR | Δ |',
|
||||
'|--------|----------|-----|---|'
|
||||
@@ -309,31 +353,31 @@ function renderColdStartReport(
|
||||
const baseSamples = baselineGroups.get(testName)
|
||||
|
||||
for (const { key, label, unit } of REPORTED_METRICS) {
|
||||
const prMean = meanMetric(prSamples, key)
|
||||
if (prMean === null) continue
|
||||
const prVal = medianMetric(prSamples, key)
|
||||
if (prVal === null) continue
|
||||
|
||||
if (!baseSamples?.length) {
|
||||
lines.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const baseVal = meanMetric(baseSamples, key)
|
||||
const baseVal = medianMetric(baseSamples, key)
|
||||
if (baseVal === null) {
|
||||
lines.push(
|
||||
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
|
||||
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
const deltaPct =
|
||||
baseVal === 0
|
||||
? prMean === 0
|
||||
? prVal === 0
|
||||
? 0
|
||||
: null
|
||||
: ((prMean - baseVal) / baseVal) * 100
|
||||
: ((prVal - baseVal) / baseVal) * 100
|
||||
lines.push(
|
||||
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |`
|
||||
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} |`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -352,14 +396,10 @@ function renderNoBaselineReport(
|
||||
)
|
||||
for (const [testName, prSamples] of prGroups) {
|
||||
for (const { key, label, unit } of REPORTED_METRICS) {
|
||||
const prMean = meanMetric(prSamples, key)
|
||||
if (prMean === null) continue
|
||||
lines.push(`| ${testName}: ${label} | ${formatValue(prMean, unit)} |`)
|
||||
const prVal = medianMetric(prSamples, key)
|
||||
if (prVal === null) continue
|
||||
lines.push(`| ${testName}: ${label} | ${formatValue(prVal, unit)} |`)
|
||||
}
|
||||
const heapMean =
|
||||
prSamples.reduce((sum, s) => sum + (s.heapDeltaBytes ?? 0), 0) /
|
||||
prSamples.length
|
||||
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -99,6 +99,21 @@ 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,12 +31,28 @@ 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
|
||||
historicalCV: number,
|
||||
absDelta?: number,
|
||||
minAbsDelta?: 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,13 +9,10 @@ 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'
|
||||
@@ -23,7 +20,6 @@ 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()
|
||||
|
||||
@@ -98,12 +94,17 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
}
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.preloadErrorTitle'),
|
||||
detail: t('g.preloadError'),
|
||||
life: 10000
|
||||
})
|
||||
// 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
|
||||
// })
|
||||
})
|
||||
|
||||
// Capture resource load failures (CSS, scripts) in non-localhost distributions
|
||||
|
||||
@@ -34,9 +34,7 @@
|
||||
"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",
|
||||
@@ -45,7 +43,6 @@
|
||||
"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,10 +25,8 @@
|
||||
"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",
|
||||
@@ -37,7 +35,6 @@
|
||||
"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,9 +34,7 @@
|
||||
"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",
|
||||
@@ -45,7 +43,6 @@
|
||||
"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,10 +26,8 @@
|
||||
"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",
|
||||
@@ -38,7 +36,6 @@
|
||||
"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,9 +34,7 @@
|
||||
"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",
|
||||
@@ -45,7 +43,6 @@
|
||||
"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,9 +19,7 @@
|
||||
"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",
|
||||
@@ -30,7 +28,6 @@
|
||||
"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 pb-2"
|
||||
class="m-0 min-w-0 p-0 px-2 pb-2"
|
||||
>
|
||||
<TreeVirtualizer
|
||||
v-slot="{ item }"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'hover:text-foreground flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
|
||||
'hover:text-foreground flex size-4 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 mx-2 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 rounded'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
|
||||
@@ -19,10 +19,7 @@ const props = defineProps<{
|
||||
|
||||
const queryString = computed(() => props.errorMessage + ' is:issue')
|
||||
|
||||
/**
|
||||
* Open GitHub issues search and track telemetry.
|
||||
*/
|
||||
const openGitHubIssues = () => {
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_find_existing_issues_clicked'
|
||||
})
|
||||
|
||||
@@ -49,7 +49,12 @@
|
||||
<Button variant="muted-textonly" size="unset" @click="dismiss">
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
>
|
||||
{{
|
||||
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
|
||||
}}
|
||||
|
||||
@@ -84,7 +84,9 @@ watch(
|
||||
pos: group.pos,
|
||||
size: [group.size[0], group.titleHeight]
|
||||
})
|
||||
inputFontStyle.value = { fontSize: `${group.font_size * scale}px` }
|
||||
inputFontStyle.value = {
|
||||
fontSize: `${LiteGraph.GROUP_TEXT_SIZE * scale}px`
|
||||
}
|
||||
} else if (target instanceof LGraphNode) {
|
||||
const node = target
|
||||
const [x, y] = node.getBounding()
|
||||
|
||||
60
src/components/input/MultiSelect.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
multiSelectDropdown: 'Multi-select dropdown',
|
||||
noResultsFound: 'No results found',
|
||||
search: 'Search',
|
||||
clearAll: 'Clear all',
|
||||
itemsSelected: 'Items selected'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('MultiSelect', () => {
|
||||
function createWrapper() {
|
||||
return mount(MultiSelect, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
},
|
||||
props: {
|
||||
modelValue: [],
|
||||
label: 'Category',
|
||||
options: [
|
||||
{ name: 'One', value: 'one' },
|
||||
{ name: 'Two', value: 'two' }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps open-state border styling available while the dropdown is open', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
|
||||
|
||||
expect(trigger.classes()).toContain(
|
||||
'data-[state=open]:border-node-component-border'
|
||||
)
|
||||
expect(trigger.attributes('aria-expanded')).toBe('false')
|
||||
|
||||
await trigger.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(trigger.attributes('aria-expanded')).toBe('true')
|
||||
expect(trigger.attributes('data-state')).toBe('open')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,207 +1,215 @@
|
||||
<template>
|
||||
<!--
|
||||
Note: Unlike SingleSelect, we don't need an explicit options prop because:
|
||||
1. Our value template only shows a static label (not dynamic based on selection)
|
||||
2. We display a count badge instead of actual selected labels
|
||||
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
|
||||
option-label="name" is required because our option template directly accesses option.name
|
||||
max-selected-labels="0" is required to show count badge instead of selected item labels
|
||||
-->
|
||||
<MultiSelect
|
||||
<ComboboxRoot
|
||||
v-model="selectedItems"
|
||||
v-bind="{ ...$attrs, options: filteredOptions }"
|
||||
option-label="name"
|
||||
unstyled
|
||||
:max-selected-labels="0"
|
||||
:pt="{
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'relative inline-flex cursor-pointer select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
|
||||
'focus-within:border-base-foreground',
|
||||
props.disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
}),
|
||||
labelContainer: {
|
||||
class: cn(
|
||||
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
},
|
||||
label: {
|
||||
class: 'p-0'
|
||||
},
|
||||
dropdown: {
|
||||
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
|
||||
},
|
||||
header: () => ({
|
||||
class:
|
||||
showSearchBox || showSelectedCount || showClearButton
|
||||
? 'block'
|
||||
: 'hidden'
|
||||
}),
|
||||
// Overlay & list visuals unchanged
|
||||
overlay: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg p-2',
|
||||
'bg-base-background',
|
||||
'text-base-foreground',
|
||||
'border border-solid border-border-default'
|
||||
)
|
||||
},
|
||||
listContainer: () => ({
|
||||
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
|
||||
class: 'scrollbar-custom'
|
||||
}),
|
||||
list: {
|
||||
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||
},
|
||||
// Option row hover and focus tone
|
||||
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2',
|
||||
'hover:bg-secondary-background-hover',
|
||||
// Add focus/highlight state for keyboard navigation
|
||||
context?.focused &&
|
||||
'bg-secondary-background-selected hover:bg-secondary-background-selected'
|
||||
)
|
||||
}),
|
||||
// Hide built-in checkboxes entirely via PT (no :deep)
|
||||
pcHeaderCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
style: { display: 'none' }
|
||||
},
|
||||
pcOptionCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
style: { display: 'none' }
|
||||
},
|
||||
emptyMessage: {
|
||||
class: 'px-3 pb-4 text-sm text-muted-foreground'
|
||||
}
|
||||
}"
|
||||
:aria-label="label || t('g.multiSelectDropdown')"
|
||||
role="combobox"
|
||||
:aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
:tabindex="0"
|
||||
multiple
|
||||
by="value"
|
||||
:disabled
|
||||
ignore-filter
|
||||
:reset-search-term-on-select="false"
|
||||
>
|
||||
<template
|
||||
v-if="showSearchBox || showSelectedCount || showClearButton"
|
||||
#header
|
||||
>
|
||||
<div class="flex flex-col px-2 pt-2 pb-0">
|
||||
<SearchInput
|
||||
v-if="showSearchBox"
|
||||
v-model="searchQuery"
|
||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||
:placeholder="searchPlaceholder"
|
||||
size="sm"
|
||||
/>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
class="mt-2 flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
class="px-1 text-sm text-base-foreground"
|
||||
>
|
||||
{{
|
||||
selectedCount > 0
|
||||
? $t('g.itemsSelected', { selectedCount })
|
||||
: $t('g.itemSelected', { selectedCount })
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
v-if="showClearButton"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
@click.stop="selectedItems = []"
|
||||
>
|
||||
{{ $t('g.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="my-4 h-px bg-border-default"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Chevron size identical to current -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</template>
|
||||
|
||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||
<template #option="slotProps">
|
||||
<div
|
||||
role="button"
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:style="popoverStyle"
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxTrigger
|
||||
v-bind="$attrs"
|
||||
:aria-label="label || t('g.multiSelectDropdown')"
|
||||
:class="
|
||||
cn(
|
||||
'relative inline-flex cursor-pointer items-center select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
selectedCount > 0
|
||||
? 'border-base-foreground'
|
||||
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
|
||||
disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
slotProps.selected
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
cn(
|
||||
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="slotProps.selected"
|
||||
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
|
||||
/>
|
||||
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
{{ slotProps.option.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
<div
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</div>
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
position="popper"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
:style="popoverStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-3000 overflow-hidden',
|
||||
'rounded-lg p-2',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default',
|
||||
'shadow-md',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2'
|
||||
)
|
||||
"
|
||||
@focus-outside="preventFocusDismiss"
|
||||
>
|
||||
<div
|
||||
v-if="showSearchBox || showSelectedCount || showClearButton"
|
||||
class="flex flex-col px-2 pt-2 pb-0"
|
||||
>
|
||||
<div
|
||||
v-if="showSearchBox"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 rounded-lg border border-solid border-border-default px-3 py-1.5',
|
||||
(showSelectedCount || showClearButton) && 'mb-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--search] shrink-0 text-sm text-muted-foreground"
|
||||
/>
|
||||
<ComboboxInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="searchPlaceholder ?? t('g.search')"
|
||||
class="w-full border-none bg-transparent text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
class="mt-2 flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
class="px-1 text-sm text-base-foreground"
|
||||
>
|
||||
{{ $t('g.itemsSelected', { count: selectedCount }) }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="showClearButton"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
@click.stop="selectedItems = []"
|
||||
>
|
||||
{{ $t('g.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="my-4 h-px bg-border-default" />
|
||||
</div>
|
||||
|
||||
<ComboboxViewport
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col gap-0 p-0 text-sm',
|
||||
'scrollbar-custom overflow-y-auto',
|
||||
'min-w-(--reka-combobox-trigger-width)'
|
||||
)
|
||||
"
|
||||
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="opt in filteredOptions"
|
||||
:key="opt.value"
|
||||
:value="opt"
|
||||
:class="
|
||||
cn(
|
||||
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
|
||||
>
|
||||
<ComboboxItemIndicator>
|
||||
<i
|
||||
class="icon-[lucide--check] text-xs font-bold text-base-foreground"
|
||||
/>
|
||||
</ComboboxItemIndicator>
|
||||
</div>
|
||||
<span>{{ opt.name }}</span>
|
||||
</ComboboxItem>
|
||||
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
|
||||
{{ $t('g.noResultsFound') }}
|
||||
</ComboboxEmpty>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFuse } from '@vueuse/integrations/useFuse'
|
||||
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
|
||||
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import type { FocusOutsideEvent } from 'reka-ui'
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot,
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport
|
||||
} from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
type Option = SelectOption
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface Props {
|
||||
const {
|
||||
label,
|
||||
options = [],
|
||||
size = 'lg',
|
||||
disabled = false,
|
||||
showSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
showClearButton = false,
|
||||
searchPlaceholder,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
} = defineProps<{
|
||||
/** Input label shown on the trigger button */
|
||||
label?: string
|
||||
/** Available options */
|
||||
options?: SelectOption[]
|
||||
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
|
||||
size?: 'lg' | 'md'
|
||||
/** Disable the select */
|
||||
disabled?: boolean
|
||||
/** Show search box in the panel header */
|
||||
showSearchBox?: boolean
|
||||
/** Show selected count text in the panel header */
|
||||
@@ -216,22 +224,9 @@ interface Props {
|
||||
popoverMinWidth?: string
|
||||
/** Maximum width of the popover (default: auto) */
|
||||
popoverMaxWidth?: string
|
||||
// Note: options prop is intentionally omitted.
|
||||
// It's passed via $attrs to maximize PrimeVue API compatibility
|
||||
}
|
||||
const {
|
||||
label,
|
||||
size = 'lg',
|
||||
showSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
showClearButton = false,
|
||||
searchPlaceholder = 'Search...',
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
} = defineProps<Props>()
|
||||
}>()
|
||||
|
||||
const selectedItems = defineModel<Option[]>({
|
||||
const selectedItems = defineModel<SelectOption[]>({
|
||||
required: true
|
||||
})
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
@@ -239,15 +234,16 @@ const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
const { t } = useI18n()
|
||||
const selectedCount = computed(() => selectedItems.value.length)
|
||||
|
||||
function preventFocusDismiss(event: FocusOutsideEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const popoverStyle = usePopoverSizing({
|
||||
minWidth: popoverMinWidth,
|
||||
maxWidth: popoverMaxWidth
|
||||
})
|
||||
const attrs = useAttrs()
|
||||
const originalOptions = computed(() => (attrs.options as Option[]) || [])
|
||||
|
||||
// Use VueUse's useFuse for better reactivity and performance
|
||||
const fuseOptions: UseFuseOptions<Option> = {
|
||||
const fuseOptions: UseFuseOptions<SelectOption> = {
|
||||
fuseOptions: {
|
||||
keys: ['name', 'value'],
|
||||
threshold: 0.3,
|
||||
@@ -256,23 +252,20 @@ const fuseOptions: UseFuseOptions<Option> = {
|
||||
matchAllWhenSearchEmpty: true
|
||||
}
|
||||
|
||||
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
|
||||
const { results } = useFuse(searchQuery, () => options, fuseOptions)
|
||||
|
||||
// Filter options based on search, but always include selected items
|
||||
const filteredOptions = computed(() => {
|
||||
if (!searchQuery.value || searchQuery.value.trim() === '') {
|
||||
return originalOptions.value
|
||||
return options
|
||||
}
|
||||
|
||||
// results.value already contains the search results from useFuse
|
||||
const searchResults = results.value.map(
|
||||
(result: { item: Option }) => result.item
|
||||
(result: { item: SelectOption }) => result.item
|
||||
)
|
||||
|
||||
// Include selected items that aren't in search results
|
||||
const selectedButNotInResults = selectedItems.value.filter(
|
||||
(item) =>
|
||||
!searchResults.some((result: Option) => result.value === item.value)
|
||||
!searchResults.some((result: SelectOption) => result.value === item.value)
|
||||
)
|
||||
|
||||
return [...selectedButNotInResults, ...searchResults]
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
<template>
|
||||
<!--
|
||||
Note: We explicitly pass options here (not just via $attrs) because:
|
||||
1. Our custom value template needs options to look up labels from values
|
||||
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
|
||||
3. We need to maintain the icon slot functionality in the value template
|
||||
option-label="name" is required because our option template directly accesses option.name
|
||||
-->
|
||||
<Select
|
||||
v-model="selectedItem"
|
||||
v-bind="$attrs"
|
||||
:options="options"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: cn(
|
||||
<SelectRoot v-model="selectedItem" :disabled>
|
||||
<SelectTrigger
|
||||
v-bind="$attrs"
|
||||
:aria-label="label || t('g.singleSelectDropdown')"
|
||||
:aria-busy="loading || undefined"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:class="
|
||||
cn(
|
||||
'relative inline-flex cursor-pointer items-center select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg',
|
||||
@@ -23,121 +14,107 @@
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
invalid
|
||||
? 'border-destructive-background'
|
||||
: 'border-transparent focus-within:border-node-component-border',
|
||||
props.disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
invalid ? 'border-destructive-background' : 'border-transparent',
|
||||
'focus:border-node-component-border focus:outline-none',
|
||||
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
|
||||
)
|
||||
}),
|
||||
label: {
|
||||
class: cn(
|
||||
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
},
|
||||
dropdown: {
|
||||
class:
|
||||
// Right chevron touch area
|
||||
'flex shrink-0 items-center justify-center px-3 py-2'
|
||||
},
|
||||
overlay: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg p-2',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default'
|
||||
)
|
||||
},
|
||||
listContainer: () => ({
|
||||
style: `max-height: min(${listMaxHeight}, 50vh)`,
|
||||
class: 'scrollbar-custom'
|
||||
}),
|
||||
list: {
|
||||
class:
|
||||
// Same list tone/size as MultiSelect
|
||||
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||
},
|
||||
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: cn(
|
||||
// Row layout
|
||||
'flex items-center justify-between gap-3 rounded-sm px-2 py-3',
|
||||
'hover:bg-secondary-background-hover',
|
||||
// Add focus state for keyboard navigation
|
||||
context.focused && 'bg-secondary-background-hover',
|
||||
// Selected state + check icon
|
||||
context.selected &&
|
||||
'bg-secondary-background-selected hover:bg-secondary-background-selected'
|
||||
)
|
||||
}),
|
||||
optionLabel: {
|
||||
class: 'truncate'
|
||||
},
|
||||
optionGroupLabel: {
|
||||
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
|
||||
},
|
||||
emptyMessage: {
|
||||
class: 'px-3 py-2 text-sm text-muted-foreground'
|
||||
}
|
||||
}"
|
||||
:aria-label="label || t('g.singleSelectDropdown')"
|
||||
:aria-busy="loading || undefined"
|
||||
:aria-invalid="invalid || undefined"
|
||||
role="combobox"
|
||||
:aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
:tabindex="0"
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
|
||||
cn(
|
||||
'flex flex-1 items-center gap-2 overflow-hidden py-2',
|
||||
size === 'md' ? 'pl-3 text-xs' : 'pl-4 text-sm'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="loading"
|
||||
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
|
||||
class="icon-[lucide--loader-circle] shrink-0 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<slot v-else name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-base-foreground"
|
||||
>
|
||||
{{ getLabel(slotProps.value) }}
|
||||
</span>
|
||||
<span v-else class="text-base-foreground">
|
||||
{{ label }}
|
||||
</span>
|
||||
<SelectValue :placeholder="label" class="truncate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger caret (hidden when loading) -->
|
||||
<template #dropdownicon>
|
||||
<i
|
||||
v-if="!loading"
|
||||
class="icon-[lucide--chevron-down] text-muted-foreground"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
<template #option="{ option, selected }">
|
||||
<div
|
||||
class="flex w-full items-center justify-between gap-3"
|
||||
:style="optionStyle"
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
|
||||
>
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
:style="optionStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-3000 overflow-hidden',
|
||||
'rounded-lg p-2',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default',
|
||||
'shadow-md',
|
||||
'min-w-(--reka-select-trigger-width)',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectViewport
|
||||
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
|
||||
class="scrollbar-custom w-full"
|
||||
>
|
||||
<SelectItem
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-pointer items-center justify-between select-none',
|
||||
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'focus:bg-secondary-background-hover',
|
||||
'data-[state=checked]:bg-secondary-background-selected',
|
||||
'data-[state=checked]:hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectItemText class="truncate">
|
||||
{{ opt.name }}
|
||||
</SelectItemText>
|
||||
<SelectItemIndicator
|
||||
class="flex shrink-0 items-center justify-center"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--check] text-base-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectPassThroughMethodOptions } from 'primevue/select'
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { SelectOption } from './types'
|
||||
@@ -152,16 +129,12 @@ const {
|
||||
size = 'lg',
|
||||
invalid = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
/**
|
||||
* Required for displaying the selected item's label.
|
||||
* Cannot rely on $attrs alone because we need to access options
|
||||
* in getLabel() to map values to their display names.
|
||||
*/
|
||||
options?: SelectOption[]
|
||||
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
|
||||
size?: 'lg' | 'md'
|
||||
@@ -169,6 +142,8 @@ const {
|
||||
invalid?: boolean
|
||||
/** Show loading spinner instead of chevron */
|
||||
loading?: boolean
|
||||
/** Disable the select */
|
||||
disabled?: boolean
|
||||
/** Maximum height of the dropdown panel (default: 28rem) */
|
||||
listMaxHeight?: string
|
||||
/** Minimum width of the popover (default: auto) */
|
||||
@@ -181,26 +156,8 @@ const selectedItem = defineModel<string | undefined>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Maps a value to its display label.
|
||||
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
|
||||
* only the raw value. We need this to show the correct text when an item is selected.
|
||||
*/
|
||||
const getLabel = (val: string | null | undefined) => {
|
||||
if (val == null) return label ?? ''
|
||||
if (!options) return label ?? ''
|
||||
const found = options.find((o) => o.value === val)
|
||||
return found ? found.name : (label ?? '')
|
||||
}
|
||||
|
||||
// Extract complex style logic from template
|
||||
const optionStyle = computed(() => {
|
||||
if (!popoverMinWidth && !popoverMaxWidth) return undefined
|
||||
|
||||
const styles: string[] = []
|
||||
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
|
||||
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
|
||||
|
||||
return styles.join('; ')
|
||||
const optionStyle = usePopoverSizing({
|
||||
minWidth: popoverMinWidth,
|
||||
maxWidth: popoverMaxWidth
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -9,12 +9,15 @@ import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
|
||||
import { st } from '@/i18n'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
@@ -38,12 +41,21 @@ import TabErrors from './errors/TabErrors.vue'
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingNodesErrorStore = useMissingNodesErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
|
||||
storeToRefs(executionErrorStore)
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
|
||||
|
||||
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.isGraphReady) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
missingNodesErrorStore.missingAncestorExecutionIds
|
||||
)
|
||||
})
|
||||
|
||||
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
|
||||
|
||||
|
||||
@@ -237,6 +237,11 @@ describe('ErrorNodeCard.vue', () => {
|
||||
|
||||
// Report is still generated with fallback log message
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serverLogs: 'Failed to retrieve server logs'
|
||||
})
|
||||
)
|
||||
expect(wrapper.text()).toContain('ComfyUI Error Report')
|
||||
})
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
{{ t('g.findOnGithub') }}
|
||||
@@ -99,6 +100,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
{{ t('g.copy') }}
|
||||
@@ -125,12 +127,10 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
const {
|
||||
@@ -154,10 +154,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const telemetry = useTelemetry()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const commandStore = useCommandStore()
|
||||
const { displayedDetailsMap } = useErrorReport(() => card)
|
||||
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
|
||||
|
||||
function handleLocateNode() {
|
||||
if (card.nodeId) {
|
||||
@@ -178,23 +176,6 @@ function handleCopyError(idx: number) {
|
||||
}
|
||||
|
||||
function handleCheckGithub(error: ErrorItem) {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_find_existing_issues_clicked'
|
||||
})
|
||||
const query = encodeURIComponent(error.message + ' is:issue')
|
||||
window.open(
|
||||
`${staticUrls.githubIssues}?q=${query}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
function handleGetHelp() {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
commandStore.execute('Comfy.ContactSupport')
|
||||
findOnGitHub(error.message)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -42,23 +40,25 @@ vi.mock('@/stores/systemStatsStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockApplyChanges = vi.fn()
|
||||
const mockIsRestarting = ref(false)
|
||||
const mockApplyChanges = vi.hoisted(() => vi.fn())
|
||||
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
|
||||
useApplyChanges: () => ({
|
||||
isRestarting: mockIsRestarting,
|
||||
get isRestarting() {
|
||||
return mockIsRestarting.value
|
||||
},
|
||||
applyChanges: mockApplyChanges
|
||||
})
|
||||
}))
|
||||
|
||||
const mockIsPackInstalled = vi.fn(() => false)
|
||||
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => ({
|
||||
isPackInstalled: mockIsPackInstalled
|
||||
})
|
||||
}))
|
||||
|
||||
const mockShouldShowManagerButtons = { value: false }
|
||||
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({
|
||||
shouldShowManagerButtons: mockShouldShowManagerButtons
|
||||
@@ -128,7 +128,7 @@ function mountCard(
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
||||
stubs: {
|
||||
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
|
||||
}
|
||||
|
||||
@@ -209,12 +209,9 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Find the copy button by text (rendered inside ErrorNodeCard)
|
||||
const copyButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Copy'))
|
||||
expect(copyButton).toBeTruthy()
|
||||
await copyButton!.trigger('click')
|
||||
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
|
||||
expect(copyButton.exists()).toBe(true)
|
||||
await copyButton.trigger('click')
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
||||
})
|
||||
@@ -245,5 +242,9 @@ describe('TabErrors.vue', () => {
|
||||
// Should render in the dedicated runtime error panel, not inside accordion
|
||||
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
|
||||
expect(runtimePanel.exists()).toBe(true)
|
||||
// Verify the error message appears exactly once (not duplicated in accordion)
|
||||
expect(
|
||||
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.title"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:collapse="isSectionCollapsed(group.title) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
@@ -209,12 +210,9 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
@@ -238,6 +236,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
@@ -246,7 +245,7 @@ import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacemen
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
@@ -372,13 +371,13 @@ watch(
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
const hasMatch =
|
||||
group.type === 'execution' &&
|
||||
group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
if (group.type !== 'execution') continue
|
||||
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
setSectionCollapsed(group.title, !hasMatch)
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
@@ -418,20 +417,4 @@ function handleReplaceAll() {
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function contactSupport() {
|
||||
useTelemetry()?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -47,7 +47,7 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
|
||||
isGroupNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -80,8 +80,7 @@ describe('swapNodeGroups computed', () => {
|
||||
})
|
||||
|
||||
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingNodes(nodeTypes)
|
||||
useMissingNodesErrorStore().surfaceMissingNodes(nodeTypes)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const t = (key: string) => key
|
||||
|
||||
39
src/components/rightSidePanel/errors/useErrorActions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
export function useErrorActions() {
|
||||
const telemetry = useTelemetry()
|
||||
const commandStore = useCommandStore()
|
||||
const { staticUrls } = useExternalLink()
|
||||
|
||||
function openGitHubIssues() {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function contactSupport() {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
function findOnGitHub(errorMessage: string) {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_find_existing_issues_clicked'
|
||||
})
|
||||
const query = encodeURIComponent(errorMessage + ' is:issue')
|
||||
window.open(
|
||||
`${staticUrls.githubIssues}?q=${query}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
return { openGitHubIssues, contactSupport, findOnGitHub }
|
||||
}
|
||||
@@ -58,6 +58,7 @@ vi.mock(
|
||||
)
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -126,8 +127,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('groups non-replaceable nodes by cnrId', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
||||
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
|
||||
@@ -146,8 +148,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('excludes replaceable nodes from missingPackGroups', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -164,8 +167,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('groups nodes without cnrId under null packId', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
|
||||
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
|
||||
])
|
||||
@@ -177,8 +181,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('sorts groups alphabetically with null packId last', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
|
||||
makeMissingNodeType('NodeB', { nodeId: '2' }),
|
||||
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
|
||||
@@ -190,8 +195,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
|
||||
@@ -206,8 +212,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('handles string nodeType entries', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
'StringGroupNode' as unknown as MissingNodeType
|
||||
])
|
||||
await nextTick()
|
||||
@@ -224,8 +231,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes missing_node group when missing nodes exist', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
@@ -237,8 +245,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes swap_nodes group when replaceable nodes exist', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -253,8 +262,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes both swap_nodes and missing_node when both exist', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -272,8 +282,9 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('swap_nodes has lower priority than missing_node', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('OldNode', {
|
||||
isReplaceable: true,
|
||||
replacement: { new_node_id: 'NewNode' }
|
||||
@@ -533,13 +544,18 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
|
||||
it('includes missing node group title as message', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.setMissingNodeTypes([
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
|
||||
const missingGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -195,12 +196,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
cardIndex: ci,
|
||||
searchableNodeId: card.nodeId ?? '',
|
||||
searchableNodeTitle: card.nodeTitle ?? '',
|
||||
searchableMessage: card.errors
|
||||
.map((e: ErrorItem) => e.message)
|
||||
.join(' '),
|
||||
searchableDetails: card.errors
|
||||
.map((e: ErrorItem) => e.details ?? '')
|
||||
.join(' ')
|
||||
searchableMessage: card.errors.map((e) => e.message).join(' '),
|
||||
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -240,6 +237,7 @@ export function useErrorGroups(
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
@@ -285,7 +283,7 @@ export function useErrorGroups(
|
||||
|
||||
const missingNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
for (const nodeType of nodeTypes) {
|
||||
if (typeof nodeType === 'string') continue
|
||||
if (nodeType.nodeId == null) continue
|
||||
@@ -407,7 +405,7 @@ export function useErrorGroups(
|
||||
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
|
||||
|
||||
const pendingTypes = computed(() =>
|
||||
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
|
||||
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
|
||||
(n): n is Exclude<MissingNodeType, string> =>
|
||||
typeof n !== 'string' && !n.cnrId
|
||||
)
|
||||
@@ -448,6 +446,8 @@ export function useErrorGroups(
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
final.set(r.value.type, r.value.packId)
|
||||
} else {
|
||||
console.warn('Failed to resolve pack ID:', r.reason)
|
||||
}
|
||||
}
|
||||
// Clear any remaining RESOLVING markers for failed lookups
|
||||
@@ -459,8 +459,18 @@ export function useErrorGroups(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Evict stale entries when missing nodes are cleared
|
||||
watch(
|
||||
() => missingNodesStore.missingNodesError,
|
||||
(error) => {
|
||||
if (!error && asyncResolvedIds.value.size > 0) {
|
||||
asyncResolvedIds.value = new Map()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const missingPackGroups = computed<MissingPackGroup[]>(() => {
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
const map = new Map<
|
||||
string | null,
|
||||
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
|
||||
@@ -522,7 +532,7 @@ export function useErrorGroups(
|
||||
})
|
||||
|
||||
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
const map = new Map<string, SwapNodeGroup>()
|
||||
|
||||
for (const nodeType of nodeTypes) {
|
||||
@@ -546,7 +556,7 @@ export function useErrorGroups(
|
||||
|
||||
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
|
||||
function buildMissingNodeGroups(): ErrorGroup[] {
|
||||
const error = executionErrorStore.missingNodesError
|
||||
const error = missingNodesStore.missingNodesError
|
||||
if (!error) return []
|
||||
|
||||
const groups: ErrorGroup[] = []
|
||||
|
||||
@@ -2,6 +2,8 @@ import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { until } from '@vueuse/core'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
@@ -40,24 +42,33 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
|
||||
if (runtimeErrors.length === 0) return
|
||||
|
||||
if (!systemStatsStore.systemStats) {
|
||||
try {
|
||||
await systemStatsStore.refetchSystemStats()
|
||||
} catch {
|
||||
return
|
||||
if (systemStatsStore.isLoading) {
|
||||
await until(systemStatsStore.isLoading).toBe(false)
|
||||
} else {
|
||||
try {
|
||||
await systemStatsStore.refetchSystemStats()
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch system stats for error report:', e)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cancelled || !systemStatsStore.systemStats) return
|
||||
|
||||
let logs: string
|
||||
try {
|
||||
logs = await api.getLogs()
|
||||
} catch {
|
||||
logs = 'Failed to retrieve server logs'
|
||||
}
|
||||
if (!systemStatsStore.systemStats || cancelled) return
|
||||
|
||||
const logs = await api
|
||||
.getLogs()
|
||||
.catch(() => 'Failed to retrieve server logs')
|
||||
if (cancelled) return
|
||||
|
||||
const workflow = app.rootGraph.serialize()
|
||||
const workflow = (() => {
|
||||
try {
|
||||
return app.rootGraph.serialize()
|
||||
} catch (e) {
|
||||
console.warn('Failed to serialize workflow for error report:', e)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (!workflow) return
|
||||
|
||||
for (const { error, idx } of runtimeErrors) {
|
||||
try {
|
||||
@@ -72,8 +83,8 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
|
||||
workflow
|
||||
})
|
||||
enrichedDetails[idx] = report
|
||||
} catch {
|
||||
// Fallback: keep original error.details
|
||||
} catch (e) {
|
||||
console.warn('Failed to generate error report:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -315,6 +315,45 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
cleanup()
|
||||
expect(graph.onNodeAdded).toBe(originalHook)
|
||||
})
|
||||
|
||||
it('restores original node callbacks when a node is removed', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('clip', 'CLIP')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {})
|
||||
const originalOnConnectionsChange = vi.fn()
|
||||
const originalOnWidgetChanged = vi.fn()
|
||||
node.onConnectionsChange = originalOnConnectionsChange
|
||||
node.onWidgetChanged = originalOnWidgetChanged
|
||||
graph.add(node)
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
// Callbacks should be chained (not the originals)
|
||||
expect(node.onConnectionsChange).not.toBe(originalOnConnectionsChange)
|
||||
expect(node.onWidgetChanged).not.toBe(originalOnWidgetChanged)
|
||||
|
||||
// Simulate node removal via the graph hook
|
||||
graph.onNodeRemoved!(node)
|
||||
|
||||
// Original callbacks should be restored
|
||||
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
|
||||
expect(node.onWidgetChanged).toBe(originalOnWidgetChanged)
|
||||
})
|
||||
|
||||
it('does not double-wrap callbacks when installErrorClearingHooks is called twice', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('clip', 'CLIP')
|
||||
graph.add(node)
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
const chainedAfterFirst = node.onConnectionsChange
|
||||
|
||||
// Install again on the same graph — should be a no-op for existing nodes
|
||||
installErrorClearingHooks(graph)
|
||||
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
@@ -35,10 +35,22 @@ function resolvePromotedExecId(
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
|
||||
type OriginalCallbacks = {
|
||||
onConnectionsChange: LGraphNode['onConnectionsChange']
|
||||
onWidgetChanged: LGraphNode['onWidgetChanged']
|
||||
}
|
||||
|
||||
const originalCallbacks = new WeakMap<LGraphNode, OriginalCallbacks>()
|
||||
|
||||
function installNodeHooks(node: LGraphNode): void {
|
||||
if (hookedNodes.has(node)) return
|
||||
hookedNodes.add(node)
|
||||
|
||||
originalCallbacks.set(node, {
|
||||
onConnectionsChange: node.onConnectionsChange,
|
||||
onWidgetChanged: node.onWidgetChanged
|
||||
})
|
||||
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
function (type, slotIndex, isConnected) {
|
||||
@@ -82,6 +94,15 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
)
|
||||
}
|
||||
|
||||
function restoreNodeHooks(node: LGraphNode): void {
|
||||
const originals = originalCallbacks.get(node)
|
||||
if (!originals) return
|
||||
node.onConnectionsChange = originals.onConnectionsChange
|
||||
node.onWidgetChanged = originals.onWidgetChanged
|
||||
originalCallbacks.delete(node)
|
||||
hookedNodes.delete(node)
|
||||
}
|
||||
|
||||
function installNodeHooksRecursive(node: LGraphNode): void {
|
||||
installNodeHooks(node)
|
||||
if (node.isSubgraphNode?.()) {
|
||||
@@ -91,6 +112,15 @@ function installNodeHooksRecursive(node: LGraphNode): void {
|
||||
}
|
||||
}
|
||||
|
||||
function restoreNodeHooksRecursive(node: LGraphNode): void {
|
||||
restoreNodeHooks(node)
|
||||
if (node.isSubgraphNode?.()) {
|
||||
for (const innerNode of node.subgraph._nodes ?? []) {
|
||||
restoreNodeHooksRecursive(innerNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
for (const node of graph._nodes ?? []) {
|
||||
installNodeHooksRecursive(node)
|
||||
@@ -102,7 +132,17 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
originalOnNodeAdded?.call(this, node)
|
||||
}
|
||||
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
graph.onNodeRemoved = function (node: LGraphNode) {
|
||||
restoreNodeHooksRecursive(node)
|
||||
originalOnNodeRemoved?.call(this, node)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const node of graph._nodes ?? []) {
|
||||
restoreNodeHooksRecursive(node)
|
||||
}
|
||||
graph.onNodeAdded = originalOnNodeAdded || undefined
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
}
|
||||
}
|
||||
|
||||
111
src/composables/graph/useNodeErrorFlagSync.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
||||
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
|
||||
if (node.has_errors === hasErrors) return
|
||||
const oldValue = node.has_errors
|
||||
node.has_errors = hasErrors
|
||||
node.graph?.trigger('node:property:changed', {
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'has_errors',
|
||||
oldValue,
|
||||
newValue: hasErrors
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-pass reconciliation of node error flags.
|
||||
* Collects the set of nodes that should have errors, then walks all nodes
|
||||
* once, setting each flag exactly once. This avoids the redundant
|
||||
* true→false→true transition (and duplicate events) that a clear-then-apply
|
||||
* approach would cause.
|
||||
*/
|
||||
function reconcileNodeErrorFlags(
|
||||
rootGraph: LGraph,
|
||||
nodeErrors: Record<string, NodeError> | null,
|
||||
missingModelExecIds: Set<string>
|
||||
): void {
|
||||
// Collect nodes and slot info that should be flagged
|
||||
// Includes both error-owning nodes and their ancestor containers
|
||||
const flaggedNodes = new Set<LGraphNode>()
|
||||
const errorSlots = new Map<LGraphNode, Set<string>>()
|
||||
|
||||
if (nodeErrors) {
|
||||
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
|
||||
const node = getNodeByExecutionId(rootGraph, executionId)
|
||||
if (!node) continue
|
||||
|
||||
flaggedNodes.add(node)
|
||||
const slotNames = new Set<string>()
|
||||
for (const error of nodeError.errors) {
|
||||
const name = error.extra_info?.input_name
|
||||
if (name) slotNames.add(name)
|
||||
}
|
||||
if (slotNames.size > 0) errorSlots.set(node, slotNames)
|
||||
|
||||
for (const parentId of getParentExecutionIds(executionId)) {
|
||||
const parentNode = getNodeByExecutionId(rootGraph, parentId)
|
||||
if (parentNode) flaggedNodes.add(parentNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const execId of missingModelExecIds) {
|
||||
const node = getNodeByExecutionId(rootGraph, execId)
|
||||
if (node) flaggedNodes.add(node)
|
||||
}
|
||||
|
||||
forEachNode(rootGraph, (node) => {
|
||||
setNodeHasErrors(node, flaggedNodes.has(node))
|
||||
|
||||
if (node.inputs) {
|
||||
const nodeSlotNames = errorSlots.get(node)
|
||||
for (const slot of node.inputs) {
|
||||
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useNodeErrorFlagSync(
|
||||
lastNodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>
|
||||
): () => void {
|
||||
const settingStore = useSettingStore()
|
||||
const showErrorsTab = computed(() =>
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
)
|
||||
|
||||
const stop = watch(
|
||||
[
|
||||
lastNodeErrors,
|
||||
() => missingModelStore.missingModelNodeIds,
|
||||
showErrorsTab
|
||||
],
|
||||
() => {
|
||||
if (!app.isGraphReady) return
|
||||
// Legacy (LGraphNode) only: suppress missing-model error flags when
|
||||
// the Errors tab is hidden, since legacy nodes lack the per-widget
|
||||
// red highlight that Vue nodes use to indicate *why* a node has errors.
|
||||
// Vue nodes compute hasAnyError independently and are unaffected.
|
||||
reconcileNodeErrorFlags(
|
||||
app.rootGraph,
|
||||
lastNodeErrors.value,
|
||||
showErrorsTab.value
|
||||
? missingModelStore.missingModelAncestorExecutionIds
|
||||
: new Set()
|
||||
)
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
return stop
|
||||
}
|
||||
65
src/composables/useClickDragGuard.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
exceedsClickThreshold,
|
||||
useClickDragGuard
|
||||
} from '@/composables/useClickDragGuard'
|
||||
|
||||
describe('exceedsClickThreshold', () => {
|
||||
it('returns false when distance is within threshold', () => {
|
||||
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 2, y: 2 }, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when distance exceeds threshold', () => {
|
||||
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 5 }, 5)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when distance exactly equals threshold', () => {
|
||||
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 4 }, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('handles negative deltas', () => {
|
||||
expect(exceedsClickThreshold({ x: 10, y: 10 }, { x: 4, y: 2 }, 5)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useClickDragGuard', () => {
|
||||
it('reports no drag when pointer has not moved', () => {
|
||||
const guard = useClickDragGuard(5)
|
||||
guard.recordStart({ clientX: 100, clientY: 200 })
|
||||
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
|
||||
})
|
||||
|
||||
it('reports no drag when movement is within threshold', () => {
|
||||
const guard = useClickDragGuard(5)
|
||||
guard.recordStart({ clientX: 100, clientY: 200 })
|
||||
expect(guard.wasDragged({ clientX: 103, clientY: 204 })).toBe(false)
|
||||
})
|
||||
|
||||
it('reports drag when movement exceeds threshold', () => {
|
||||
const guard = useClickDragGuard(5)
|
||||
guard.recordStart({ clientX: 100, clientY: 200 })
|
||||
expect(guard.wasDragged({ clientX: 106, clientY: 200 })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no start has been recorded', () => {
|
||||
const guard = useClickDragGuard(5)
|
||||
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false after reset', () => {
|
||||
const guard = useClickDragGuard(5)
|
||||
guard.recordStart({ clientX: 100, clientY: 200 })
|
||||
guard.reset()
|
||||
expect(guard.wasDragged({ clientX: 200, clientY: 300 })).toBe(false)
|
||||
})
|
||||
|
||||
it('respects custom threshold', () => {
|
||||
const guard = useClickDragGuard(3)
|
||||
guard.recordStart({ clientX: 0, clientY: 0 })
|
||||
expect(guard.wasDragged({ clientX: 3, clientY: 0 })).toBe(false)
|
||||
expect(guard.wasDragged({ clientX: 4, clientY: 0 })).toBe(true)
|
||||
})
|
||||
})
|
||||
41
src/composables/useClickDragGuard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
interface PointerPosition {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
function squaredDistance(a: PointerPosition, b: PointerPosition): number {
|
||||
const dx = a.x - b.x
|
||||
const dy = a.y - b.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
export function exceedsClickThreshold(
|
||||
start: PointerPosition,
|
||||
end: PointerPosition,
|
||||
threshold: number
|
||||
): boolean {
|
||||
return squaredDistance(start, end) > threshold * threshold
|
||||
}
|
||||
|
||||
export function useClickDragGuard(threshold: number = 5) {
|
||||
let start: PointerPosition | null = null
|
||||
|
||||
function recordStart(e: { clientX: number; clientY: number }) {
|
||||
start = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function wasDragged(e: { clientX: number; clientY: number }): boolean {
|
||||
if (!start) return false
|
||||
return exceedsClickThreshold(
|
||||
start,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
threshold
|
||||
)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
start = null
|
||||
}
|
||||
|
||||
return { recordStart, wasDragged, reset }
|
||||
}
|
||||
94
src/composables/useCopyToClipboard.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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,34 +3,60 @@ 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, copied } = useClipboard({ legacy: true })
|
||||
const { copy, isSupported } = useClipboard()
|
||||
const toast = useToast()
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
let success = false
|
||||
|
||||
try {
|
||||
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')
|
||||
})
|
||||
if (isSupported.value) {
|
||||
await copy(text)
|
||||
success = true
|
||||
}
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
// Modern clipboard API failed, fall through to legacy
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -107,6 +107,27 @@ export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
|
||||
EssentialsCategory
|
||||
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
|
||||
|
||||
/**
|
||||
* Precomputed rank map: category → display order index.
|
||||
* Used for sorting essentials folders in their canonical order.
|
||||
*/
|
||||
export const ESSENTIALS_CATEGORY_RANK: ReadonlyMap<string, number> = new Map(
|
||||
ESSENTIALS_CATEGORIES.map((c, i) => [c, i])
|
||||
)
|
||||
|
||||
/**
|
||||
* Precomputed rank maps: category → (node name → display order index).
|
||||
* Used for sorting nodes within each essentials folder.
|
||||
*/
|
||||
export const ESSENTIALS_NODE_RANK: Partial<
|
||||
Record<EssentialsCategory, ReadonlyMap<string, number>>
|
||||
> = Object.fromEntries(
|
||||
Object.entries(ESSENTIALS_NODES).map(([category, nodes]) => [
|
||||
category,
|
||||
new Map(nodes.map((name, i) => [name, i]))
|
||||
])
|
||||
)
|
||||
|
||||
/**
|
||||
* "Novel" toolkit nodes for telemetry — basics excluded.
|
||||
* Derived from ESSENTIALS_NODES minus the 'basics' category.
|
||||
|
||||
@@ -751,7 +751,7 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('full linked coverage does not prune unresolved independent fallback promotions', () => {
|
||||
test('full linked coverage prunes promotions referencing non-existent nodes', () => {
|
||||
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: '9999', sourceWidgetName: 'widgetA' }
|
||||
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' }
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
@@ -68,9 +70,7 @@ class Load3d {
|
||||
targetAspectRatio: number = 1
|
||||
isViewerMode: boolean = false
|
||||
|
||||
// Context menu tracking
|
||||
private rightMouseDownX: number = 0
|
||||
private rightMouseDownY: number = 0
|
||||
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
|
||||
private rightMouseMoved: boolean = false
|
||||
private readonly dragThreshold: number = 5
|
||||
private contextMenuAbortController: AbortController | null = null
|
||||
@@ -197,18 +197,20 @@ class Load3d {
|
||||
|
||||
const mousedownHandler = (e: MouseEvent) => {
|
||||
if (e.button === 2) {
|
||||
this.rightMouseDownX = e.clientX
|
||||
this.rightMouseDownY = e.clientY
|
||||
this.rightMouseStart = { x: e.clientX, y: e.clientY }
|
||||
this.rightMouseMoved = false
|
||||
}
|
||||
}
|
||||
|
||||
const mousemoveHandler = (e: MouseEvent) => {
|
||||
if (e.buttons === 2) {
|
||||
const dx = Math.abs(e.clientX - this.rightMouseDownX)
|
||||
const dy = Math.abs(e.clientY - this.rightMouseDownY)
|
||||
|
||||
if (dx > this.dragThreshold || dy > this.dragThreshold) {
|
||||
if (
|
||||
exceedsClickThreshold(
|
||||
this.rightMouseStart,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
this.dragThreshold
|
||||
)
|
||||
) {
|
||||
this.rightMouseMoved = true
|
||||
}
|
||||
}
|
||||
@@ -217,12 +219,13 @@ class Load3d {
|
||||
const contextmenuHandler = (e: MouseEvent) => {
|
||||
if (this.isViewerMode) return
|
||||
|
||||
const dx = Math.abs(e.clientX - this.rightMouseDownX)
|
||||
const dy = Math.abs(e.clientY - this.rightMouseDownY)
|
||||
const wasDragging =
|
||||
this.rightMouseMoved ||
|
||||
dx > this.dragThreshold ||
|
||||
dy > this.dragThreshold
|
||||
exceedsClickThreshold(
|
||||
this.rightMouseStart,
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
this.dragThreshold
|
||||
)
|
||||
|
||||
this.rightMouseMoved = false
|
||||
|
||||
|
||||
@@ -2616,8 +2616,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
pointer.finally = () => (this.resizingGroup = null)
|
||||
} else {
|
||||
const f = group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE
|
||||
const headerHeight = f * 1.4
|
||||
const headerHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
if (
|
||||
isInRectangle(
|
||||
x,
|
||||
|
||||
@@ -40,7 +40,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
color?: string
|
||||
title: string
|
||||
font?: string
|
||||
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
|
||||
font_size: number = LiteGraph.GROUP_TEXT_SIZE
|
||||
_bounding = new Rectangle(10, 10, LGraphGroup.minWidth, LGraphGroup.minHeight)
|
||||
|
||||
_pos: Point = this._bounding.pos
|
||||
@@ -116,7 +116,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
}
|
||||
|
||||
get titleHeight() {
|
||||
return this.font_size * 1.4
|
||||
return LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
get children(): ReadonlySet<Positionable> {
|
||||
@@ -148,7 +148,6 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
this._bounding.set(o.bounding)
|
||||
this.color = o.color
|
||||
this.flags = o.flags || this.flags
|
||||
if (o.font_size) this.font_size = o.font_size
|
||||
}
|
||||
|
||||
serialize(): ISerialisedGroup {
|
||||
@@ -158,7 +157,6 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
title: this.title,
|
||||
bounding: [...b],
|
||||
color: this.color,
|
||||
font_size: this.font_size,
|
||||
flags: this.flags
|
||||
}
|
||||
}
|
||||
@@ -170,7 +168,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
*/
|
||||
draw(graphCanvas: LGraphCanvas, ctx: CanvasRenderingContext2D): void {
|
||||
const { padding, resizeLength, defaultColour } = LGraphGroup
|
||||
const font_size = this.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE
|
||||
const font_size = LiteGraph.GROUP_TEXT_SIZE
|
||||
|
||||
const [x, y] = this._pos
|
||||
const [width, height] = this._size
|
||||
@@ -181,7 +179,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
ctx.fillStyle = color
|
||||
ctx.strokeStyle = color
|
||||
ctx.beginPath()
|
||||
ctx.rect(x + 0.5, y + 0.5, width, font_size * 1.4)
|
||||
ctx.rect(x + 0.5, y + 0.5, width, LiteGraph.NODE_TITLE_HEIGHT)
|
||||
ctx.fill()
|
||||
|
||||
// Group background, border
|
||||
@@ -203,11 +201,13 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
// Title
|
||||
ctx.font = `${font_size}px ${LiteGraph.GROUP_FONT}`
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(
|
||||
this.title + (this.pinned ? '📌' : ''),
|
||||
x + padding,
|
||||
y + font_size
|
||||
x + font_size / 2,
|
||||
y + LiteGraph.NODE_TITLE_HEIGHT / 2 + 1
|
||||
)
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
|
||||
if (LiteGraph.highlight_selected_group && this.selected) {
|
||||
strokeShape(ctx, this._bounding, {
|
||||
|
||||
@@ -72,8 +72,7 @@ export class LiteGraphGlobal {
|
||||
DEFAULT_FONT = 'Inter'
|
||||
DEFAULT_SHADOW_COLOR = 'rgba(0,0,0,0.5)'
|
||||
|
||||
DEFAULT_GROUP_FONT = 24
|
||||
DEFAULT_GROUP_FONT_SIZE = 24
|
||||
GROUP_TEXT_SIZE = 20
|
||||
GROUP_FONT = 'Inter'
|
||||
|
||||
WIDGET_BGCOLOR = '#222'
|
||||
|
||||
@@ -18,7 +18,6 @@ exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
|
||||
],
|
||||
"color": "#6029aa",
|
||||
"flags": {},
|
||||
"font_size": 14,
|
||||
"id": 123,
|
||||
"title": "A group to test with",
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ exports[`LGraphGroup > serializes to the existing format > Basic 1`] = `
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"flags": {},
|
||||
"font_size": 24,
|
||||
"id": 929,
|
||||
"title": "title",
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ LiteGraphGlobal {
|
||||
"ContextMenu": [Function],
|
||||
"CurveEditor": [Function],
|
||||
"DEFAULT_FONT": "Inter",
|
||||
"DEFAULT_GROUP_FONT": 24,
|
||||
"DEFAULT_GROUP_FONT_SIZE": 24,
|
||||
"DEFAULT_POSITION": [
|
||||
100,
|
||||
100,
|
||||
@@ -34,6 +32,7 @@ LiteGraphGlobal {
|
||||
"EVENT_LINK_COLOR": "#A86",
|
||||
"GRID_SHAPE": 6,
|
||||
"GROUP_FONT": "Inter",
|
||||
"GROUP_TEXT_SIZE": 20,
|
||||
"Globals": {},
|
||||
"HIDDEN_LINK": -1,
|
||||
"INPUT": 1,
|
||||
|
||||
@@ -958,3 +958,69 @@ describe('SubgraphNode promotion view keys', () => {
|
||||
expect(firstKey).not.toBe(secondKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode label propagation', () => {
|
||||
it('should preserve input labels from configure path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps', type: 'number' }]
|
||||
})
|
||||
subgraph.inputs[0].label = 'Steps Count'
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.inputs[0].label).toBe('Steps Count')
|
||||
expect(subgraphNode.inputs[0].name).toBe('steps')
|
||||
})
|
||||
|
||||
it('should preserve output labels from configure path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'result', type: 'number' }]
|
||||
})
|
||||
subgraph.outputs[0].label = 'Final Result'
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.outputs[0].label).toBe('Final Result')
|
||||
expect(subgraphNode.outputs[0].name).toBe('result')
|
||||
})
|
||||
|
||||
it('should propagate label via renaming-input event', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
subgraph.addInput('steps', 'number')
|
||||
expect(subgraphNode.inputs[0].label).toBeUndefined()
|
||||
|
||||
subgraph.renameInput(subgraph.inputs[0], 'Steps Count')
|
||||
|
||||
expect(subgraphNode.inputs[0].label).toBe('Steps Count')
|
||||
expect(subgraphNode.inputs[0].name).toBe('steps')
|
||||
})
|
||||
|
||||
it('should propagate label via renaming-output event', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
subgraph.addOutput('result', 'number')
|
||||
expect(subgraphNode.outputs[0].label).toBeUndefined()
|
||||
|
||||
subgraph.renameOutput(subgraph.outputs[0], 'Final Result')
|
||||
|
||||
expect(subgraphNode.outputs[0].label).toBe('Final Result')
|
||||
expect(subgraphNode.outputs[0].name).toBe('result')
|
||||
})
|
||||
|
||||
it('should preserve localized_name from configure path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps', type: 'number' }],
|
||||
outputs: [{ name: 'result', type: 'number' }]
|
||||
})
|
||||
subgraph.inputs[0].localized_name = 'ステップ'
|
||||
subgraph.outputs[0].localized_name = '結果'
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.inputs[0].localized_name).toBe('ステップ')
|
||||
expect(subgraphNode.outputs[0].localized_name).toBe('結果')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -515,6 +515,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const prunedEntries: PromotedWidgetSource[] = []
|
||||
|
||||
for (const entry of fallbackStoredEntries) {
|
||||
if (!this.subgraph.getNodeById(entry.sourceNodeId)) continue
|
||||
|
||||
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
|
||||
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
|
||||
|
||||
@@ -1063,6 +1065,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!this.subgraph.getNodeById(nodeId)) return null
|
||||
const entry: PromotedWidgetSource = {
|
||||
sourceNodeId: nodeId,
|
||||
sourceWidgetName: widgetName,
|
||||
@@ -1074,8 +1077,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
store.setPromotions(this.rootGraph.id, this.id, entries)
|
||||
|
||||
// Write back resolved entries so legacy -1 format doesn't persist
|
||||
if (raw.some(([id]) => id === '-1')) {
|
||||
// Write back resolved entries so legacy or stale entries don't persist
|
||||
if (entries.length !== raw.length) {
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
import {
|
||||
createEventCapture,
|
||||
@@ -263,6 +264,62 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Nested Subgraph Widget Promotion', () => {
|
||||
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
|
||||
// Reproduces the bug where packing nodes into a nested subgraph leaves
|
||||
// stale proxyWidgets on the outer subgraph node referencing grandchild
|
||||
// node IDs that no longer exist directly in the outer subgraph.
|
||||
// Uses 3 inputs with only 1 having a linked widget entry, matching the
|
||||
// real workflow structure where model/vae inputs don't resolve widgets.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'clip', type: 'CLIP' },
|
||||
{ name: 'model', type: 'MODEL' },
|
||||
{ name: 'vae', type: 'VAE' }
|
||||
]
|
||||
})
|
||||
|
||||
const { node: samplerNode } = createNodeWithWidget(
|
||||
'Sampler',
|
||||
'number',
|
||||
42,
|
||||
'number'
|
||||
)
|
||||
subgraph.add(samplerNode)
|
||||
subgraph.inputNode.slots[1].connect(samplerNode.inputs[0], samplerNode)
|
||||
|
||||
// Add nodes without widget-connected inputs for the other slots
|
||||
const modelNode = new LGraphNode('ModelNode')
|
||||
modelNode.addInput('model', 'MODEL')
|
||||
subgraph.add(modelNode)
|
||||
|
||||
const vaeNode = new LGraphNode('VAENode')
|
||||
vaeNode.addInput('vae', 'VAE')
|
||||
subgraph.add(vaeNode)
|
||||
|
||||
const outerNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Inject stale proxyWidgets referencing nodes that don't exist in
|
||||
// this subgraph (they were packed into a nested subgraph)
|
||||
outerNode.properties.proxyWidgets = [
|
||||
['999', 'text'],
|
||||
['998', 'text'],
|
||||
[String(samplerNode.id), 'widget']
|
||||
]
|
||||
|
||||
outerNode.configure(outerNode.serialize())
|
||||
|
||||
// Check widgets getter — stale entries should not produce views
|
||||
const widgetSourceIds = outerNode.widgets
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((w) => !w.name.startsWith('$$'))
|
||||
.map((w) => w.sourceNodeId)
|
||||
|
||||
expect(widgetSourceIds).not.toContain('999')
|
||||
expect(widgetSourceIds).not.toContain('998')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip Promotion', () => {
|
||||
it('should preserve widget tooltip when promoting', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
|
||||
@@ -564,19 +564,26 @@
|
||||
"uploadCover": "+ رفع صورة الغلاف",
|
||||
"uploadProfilePicture": "+ رفع صورة الملف الشخصي",
|
||||
"uploadWorkflowButton": "رفع سير عملي",
|
||||
"usernameError": "٣–٤٢ حرفًا صغيرًا أو رقمًا أو شرطة، ويجب أن يبدأ وينتهي بحرف أو رقم",
|
||||
"usernameLabel": "اسم المستخدم (إجباري)",
|
||||
"usernamePlaceholder": "@"
|
||||
},
|
||||
"comfyHubPublish": {
|
||||
"additionalInfo": "معلومات إضافية",
|
||||
"back": "رجوع",
|
||||
"createProfileCta": "إنشاء ملف شخصي",
|
||||
"createProfileToPublish": "أنشئ ملفًا شخصيًا للنشر على ComfyHub",
|
||||
"exampleImage": "صورة نموذجية {index}",
|
||||
"exampleImagePosition": "الصورة النموذجية {index} من {total}",
|
||||
"examplesDescription": "أضف حتى {total} صورة نموذجية إضافية",
|
||||
"maxExamples": "يمكنك اختيار حتى {max} أمثلة",
|
||||
"next": "التالي",
|
||||
"publishButton": "النشر على ComfyHub",
|
||||
"publishFailedDescription": "حدث خطأ أثناء نشر سير العمل الخاص بك. يرجى المحاولة مرة أخرى.",
|
||||
"publishFailedTitle": "فشل النشر",
|
||||
"removeExampleImage": "إزالة الصورة النموذجية",
|
||||
"selectAThumbnail": "اختر صورة مصغرة",
|
||||
"shareAs": "مشاركة كـ",
|
||||
"showLessTags": "عرض أقل...",
|
||||
"showMoreTags": "عرض المزيد...",
|
||||
"stepDescribe": "وصف سير العمل",
|
||||
@@ -591,6 +598,7 @@
|
||||
"thumbnailPreview": "معاينة الصورة المصغرة",
|
||||
"thumbnailVideo": "فيديو",
|
||||
"title": "النشر على ComfyHub",
|
||||
"unsavedDescription": "يجب حفظ سير العمل الخاص بك قبل النشر على ComfyHub. احفظه الآن للمتابعة.",
|
||||
"uploadAnImage": "انقر للاستعراض أو اسحب صورة",
|
||||
"uploadComparison": "رفع صورة قبل وبعد",
|
||||
"uploadComparisonAfterPrompt": "بعد",
|
||||
@@ -606,13 +614,7 @@
|
||||
"workflowDescription": "وصف سير العمل",
|
||||
"workflowDescriptionPlaceholder": "ما الذي يجعل سير عملك مميزًا ومثيرًا؟ كن محددًا حتى يعرف الآخرون ما يمكن توقعه.",
|
||||
"workflowName": "اسم سير العمل",
|
||||
"workflowNamePlaceholder": "نصيحة: أدخل اسمًا وصفيًا يسهل البحث عنه",
|
||||
"workflowType": "نوع سير العمل",
|
||||
"workflowTypeEditing": "تحرير",
|
||||
"workflowTypeImageGeneration": "توليد الصور",
|
||||
"workflowTypePlaceholder": "اختر النوع",
|
||||
"workflowTypeUpscaling": "تحسين الجودة",
|
||||
"workflowTypeVideoGeneration": "توليد الفيديو"
|
||||
"workflowNamePlaceholder": "نصيحة: أدخل اسمًا وصفيًا يسهل البحث عنه"
|
||||
},
|
||||
"commands": {
|
||||
"clear": "مسح سير العمل",
|
||||
@@ -780,6 +782,7 @@
|
||||
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
|
||||
"CONDITIONING": "تكييف",
|
||||
"CONTROL_NET": "ControlNet",
|
||||
"CURVE": "منحنى",
|
||||
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
|
||||
"FILE_3D": "ملف ثلاثي الأبعاد",
|
||||
"FILE_3D_FBX": "ملف FBX ثلاثي الأبعاد",
|
||||
@@ -793,6 +796,7 @@
|
||||
"GEMINI_INPUT_FILES": "ملفات إدخال جيميني",
|
||||
"GLIGEN": "GLIGEN",
|
||||
"GUIDER": "موجه",
|
||||
"HISTOGRAM": "مخطط بياني",
|
||||
"HOOKS": "معالجات",
|
||||
"HOOK_KEYFRAMES": "مفاتيح المعالجات",
|
||||
"IMAGE": "صورة",
|
||||
@@ -884,13 +888,13 @@
|
||||
"resume": "استئناف التنزيل"
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"defaultTitle": "حدث خطأ",
|
||||
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
|
||||
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
|
||||
"noStackTrace": "لا توجد تتبع للمكدس متاحة",
|
||||
"promptExecutionError": "فشل تنفيذ الطلب",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
"promptExecutionError": "فشل تنفيذ الطلب"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} خطأ | {count} أخطاء",
|
||||
@@ -934,6 +938,19 @@
|
||||
"textToImage": "تحويل نص إلى صورة",
|
||||
"textToVideo": "تحويل نص إلى فيديو"
|
||||
},
|
||||
"execution": {
|
||||
"decoding": "جارٍ فك الترميز…",
|
||||
"encoding": "جارٍ الترميز…",
|
||||
"generating": "جارٍ التوليد…",
|
||||
"generatingVideo": "جارٍ توليد الفيديو…",
|
||||
"loading": "جارٍ التحميل…",
|
||||
"processing": "جارٍ المعالجة…",
|
||||
"processingVideo": "جارٍ معالجة الفيديو…",
|
||||
"resizing": "جارٍ تغيير الحجم…",
|
||||
"running": "جارٍ التشغيل…",
|
||||
"saving": "جارٍ الحفظ…",
|
||||
"training": "جارٍ التدريب…"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "اكتملت جميع عمليات التصدير",
|
||||
"downloadExport": "تحميل التصدير",
|
||||
@@ -1090,6 +1107,7 @@
|
||||
"icon": "أيقونة",
|
||||
"imageDoesNotExist": "الصورة غير موجودة",
|
||||
"imageFailedToLoad": "فشل تحميل الصورة",
|
||||
"imageGallery": "معرض الصور",
|
||||
"imageLightbox": "معاينة الصورة",
|
||||
"imagePreview": "معاينة الصورة - استخدم مفاتيح الأسهم للتنقل بين الصور",
|
||||
"imageUrl": "رابط الصورة",
|
||||
@@ -1104,7 +1122,6 @@
|
||||
"installed": "مثبت",
|
||||
"installing": "جارٍ التثبيت",
|
||||
"interrupted": "تمت المقاطعة",
|
||||
"itemSelected": "تم تحديد عنصر واحد",
|
||||
"itemsCopiedToClipboard": "تم نسخ العناصر إلى الحافظة",
|
||||
"itemsSelected": "تم تحديد {selectedCount} عناصر",
|
||||
"job": "مهمة",
|
||||
@@ -1310,6 +1327,7 @@
|
||||
"versionMismatchWarningMessage": "{warning}: {detail} زر https://docs.comfy.org/installation/update_comfyui#common-update-issues للحصول على تعليمات التحديث.",
|
||||
"videoFailedToLoad": "فشل تحميل الفيديو",
|
||||
"videoPreview": "معاينة الفيديو - استخدم مفاتيح الأسهم للتنقل بين الفيديوهات",
|
||||
"viewGrid": "عرض الشبكة",
|
||||
"viewImageOfTotal": "عرض الصورة {index} من {total}",
|
||||
"viewVideoOfTotal": "عرض الفيديو {index} من {total}",
|
||||
"volume": "مستوى الصوت",
|
||||
|
||||
@@ -1438,6 +1438,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ComfyNumberConvert": {
|
||||
"display_name": "تحويل الرقم",
|
||||
"inputs": {
|
||||
"value": {
|
||||
"name": "القيمة"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ComfySwitchNode": {
|
||||
"display_name": "مفتاح التحويل",
|
||||
"inputs": {
|
||||
@@ -2218,6 +2234,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CurveEditor": {
|
||||
"display_name": "محرر المنحنى",
|
||||
"inputs": {
|
||||
"curve": {
|
||||
"name": "منحنى"
|
||||
},
|
||||
"histogram": {
|
||||
"name": "مخطط بياني"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "منحنى",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CustomCombo": {
|
||||
"display_name": "توليفة مخصصة",
|
||||
"inputs": {
|
||||
@@ -4092,6 +4125,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrokVideoExtendNode": {
|
||||
"description": "تمديد فيديو موجود باستمرار سلس بناءً على وصف نصي.",
|
||||
"display_name": "تمديد فيديو Grok",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج المستخدم لتمديد الفيديو."
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "المدة"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الوصف النصي",
|
||||
"tooltip": "وصف نصي لما يجب أن يحدث بعد ذلك في الفيديو."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"video": {
|
||||
"name": "فيديو",
|
||||
"tooltip": "الفيديو المصدر للتمديد. صيغة MP4، من ٢ إلى ١٥ ثانية."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrokVideoNode": {
|
||||
"description": "توليد فيديو من مطالبة أو صورة",
|
||||
"display_name": "فيديو Grok",
|
||||
@@ -4132,6 +4198,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrokVideoReferenceNode": {
|
||||
"description": "توليد فيديو موجه بواسطة صور مرجعية كمرجع للأسلوب والمحتوى.",
|
||||
"display_name": "Grok من مرجع إلى فيديو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج المستخدم لتوليد الفيديو."
|
||||
},
|
||||
"model_aspect_ratio": {
|
||||
"name": "نسبة العرض إلى الارتفاع"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "المدة"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الوصف النصي",
|
||||
"tooltip": "وصف نصي للفيديو المطلوب."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrowMask": {
|
||||
"display_name": "توسيع القناع",
|
||||
"inputs": {
|
||||
@@ -6748,6 +6849,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVReferenceAudio": {
|
||||
"description": "تعيين صوت مرجعي لنقل هوية المتحدث باستخدام ID-LoRA. يقوم بترميز مقطع صوتي مرجعي إلى التكييف، ويمكنه أيضًا تعديل النموذج بتوجيه الهوية (تمرير إضافي للأمام بدون المرجع، مما يعزز تأثير هوية المتحدث).",
|
||||
"display_name": "LTXV مرجع الصوت (ID-LoRA)",
|
||||
"inputs": {
|
||||
"audio_vae": {
|
||||
"name": "audio_vae",
|
||||
"tooltip": "LTXV Audio VAE للترميز."
|
||||
},
|
||||
"end_percent": {
|
||||
"name": "نسبة النهاية",
|
||||
"tooltip": "نهاية نطاق سيغما حيث يكون توجيه الهوية نشطًا."
|
||||
},
|
||||
"identity_guidance_scale": {
|
||||
"name": "مقياس توجيه الهوية",
|
||||
"tooltip": "قوة توجيه الهوية. ينفذ تمريرًا إضافيًا للأمام بدون المرجع في كل خطوة لتعزيز هوية المتحدث. اضبط على ٠ للتعطيل (بدون تمرير إضافي)."
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج"
|
||||
},
|
||||
"negative": {
|
||||
"name": "سلبي"
|
||||
},
|
||||
"positive": {
|
||||
"name": "إيجابي"
|
||||
},
|
||||
"reference_audio": {
|
||||
"name": "الصوت_المرجعي",
|
||||
"tooltip": "مقطع صوتي مرجعي لنقل هوية المتحدث. يُوصى بأن يكون حوالي ٥ ثوانٍ (مدة التدريب). المقاطع الأقصر أو الأطول قد تؤثر سلبًا على نقل هوية الصوت."
|
||||
},
|
||||
"start_percent": {
|
||||
"name": "نسبة البداية",
|
||||
"tooltip": "بداية نطاق سيغما حيث يكون توجيه الهوية نشطًا."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "إيجابي",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "سلبي",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVScheduler": {
|
||||
"display_name": "LTXV المجدول",
|
||||
"inputs": {
|
||||
@@ -16091,6 +16240,10 @@
|
||||
"name": "إيجابي",
|
||||
"tooltip": "التكييف الإيجابي المستخدم في التدريب."
|
||||
},
|
||||
"quantized_backward": {
|
||||
"name": "quantized_backward",
|
||||
"tooltip": "عند استخدام نوع التدريب 'none' والتدريب على نموذج كمي، يتم تنفيذ عملية الرجوع للخلف باستخدام ضرب المصفوفات الكمي عند التفعيل."
|
||||
},
|
||||
"rank": {
|
||||
"name": "الرتبة",
|
||||
"tooltip": "رتبة طبقات LoRA."
|
||||
|
||||
@@ -278,8 +278,7 @@
|
||||
"clearAll": "Clear all",
|
||||
"copyURL": "Copy URL",
|
||||
"releaseTitle": "{package} {version} Release",
|
||||
"itemSelected": "{selectedCount} item selected",
|
||||
"itemsSelected": "{selectedCount} items selected",
|
||||
"itemsSelected": "No items selected | {count} item selected | {count} items selected",
|
||||
"multiSelectDropdown": "Multi-select dropdown",
|
||||
"singleSelectDropdown": "Single-select dropdown",
|
||||
"progressCountOf": "of",
|
||||
@@ -1767,6 +1766,7 @@
|
||||
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
|
||||
"CONDITIONING": "CONDITIONING",
|
||||
"CONTROL_NET": "CONTROL_NET",
|
||||
"CURVE": "CURVE",
|
||||
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
|
||||
"FILE_3D": "FILE_3D",
|
||||
"FILE_3D_FBX": "FILE_3D_FBX",
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"GEMINI_INPUT_FILES": "GEMINI_INPUT_FILES",
|
||||
"GLIGEN": "GLIGEN",
|
||||
"GUIDER": "GUIDER",
|
||||
"HISTOGRAM": "HISTOGRAM",
|
||||
"HOOK_KEYFRAMES": "HOOK_KEYFRAMES",
|
||||
"HOOKS": "HOOKS",
|
||||
"IMAGE": "IMAGE",
|
||||
@@ -3166,6 +3167,7 @@
|
||||
},
|
||||
"comfyHubPublish": {
|
||||
"title": "Publish to ComfyHub",
|
||||
"unsavedDescription": "You must save your workflow before publishing to ComfyHub. Save it now to continue.",
|
||||
"stepDescribe": "Describe your workflow",
|
||||
"stepExamples": "Add output examples",
|
||||
"stepFinish": "Finish publishing",
|
||||
@@ -3173,12 +3175,6 @@
|
||||
"workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
|
||||
"workflowDescription": "Workflow description",
|
||||
"workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
|
||||
"workflowType": "Workflow type",
|
||||
"workflowTypePlaceholder": "Select the type",
|
||||
"workflowTypeImageGeneration": "Image generation",
|
||||
"workflowTypeVideoGeneration": "Video generation",
|
||||
"workflowTypeUpscaling": "Upscaling",
|
||||
"workflowTypeEditing": "Editing",
|
||||
"tags": "Tags",
|
||||
"tagsDescription": "Select tags so people can find your workflow faster",
|
||||
"tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
|
||||
@@ -3205,11 +3201,17 @@
|
||||
"examplesDescription": "Add up to {total} additional sample images",
|
||||
"uploadAnImage": "Click to browse or drag an image",
|
||||
"uploadExampleImage": "Upload example image",
|
||||
"removeExampleImage": "Remove example image",
|
||||
"exampleImage": "Example image {index}",
|
||||
"exampleImagePosition": "Example image {index} of {total}",
|
||||
"videoPreview": "Video thumbnail preview",
|
||||
"maxExamples": "You can select up to {max} examples",
|
||||
"shareAs": "Share as",
|
||||
"additionalInfo": "Additional information",
|
||||
"createProfileToPublish": "Create a profile to publish to ComfyHub",
|
||||
"createProfileCta": "Create a profile"
|
||||
"createProfileCta": "Create a profile",
|
||||
"publishFailedTitle": "Publish failed",
|
||||
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again."
|
||||
},
|
||||
"comfyHubProfile": {
|
||||
"checkingAccess": "Checking your publishing access...",
|
||||
@@ -3228,6 +3230,7 @@
|
||||
"namePlaceholder": "Enter your name here",
|
||||
"usernameLabel": "Your username (required)",
|
||||
"usernamePlaceholder": "@",
|
||||
"usernameError": "3–42 lowercase alphanumeric characters and hyphens, must start and end with a letter or number",
|
||||
"descriptionLabel": "Your description",
|
||||
"descriptionPlaceholder": "Tell the community about yourself...",
|
||||
"createProfile": "Create profile",
|
||||
@@ -3707,5 +3710,18 @@
|
||||
"footer": "ComfyUI stays free and open source. Cloud is optional.",
|
||||
"continueLocally": "Continue Locally",
|
||||
"exploreCloud": "Try Cloud for Free"
|
||||
},
|
||||
"execution": {
|
||||
"generating": "Generating…",
|
||||
"saving": "Saving…",
|
||||
"loading": "Loading…",
|
||||
"encoding": "Encoding…",
|
||||
"decoding": "Decoding…",
|
||||
"processing": "Processing…",
|
||||
"resizing": "Resizing…",
|
||||
"generatingVideo": "Generating video…",
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,6 +1438,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ComfyNumberConvert": {
|
||||
"display_name": "Number Convert",
|
||||
"inputs": {
|
||||
"value": {
|
||||
"name": "value"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ComfySwitchNode": {
|
||||
"display_name": "Switch",
|
||||
"inputs": {
|
||||
@@ -2218,6 +2234,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CurveEditor": {
|
||||
"display_name": "Curve Editor",
|
||||
"inputs": {
|
||||
"curve": {
|
||||
"name": "curve"
|
||||
},
|
||||
"histogram": {
|
||||
"name": "histogram"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "curve",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CustomCombo": {
|
||||
"display_name": "Custom Combo",
|
||||
"inputs": {
|
||||
@@ -4092,6 +4125,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrokVideoExtendNode": {
|
||||
"display_name": "Grok Video Extend",
|
||||
"description": "Extend an existing video with a seamless continuation based on a text prompt.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Text description of what should happen next in the video."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Source video to extend. MP4 format, 2-15 seconds."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "The model to use for video extension."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duration"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrokVideoNode": {
|
||||
"display_name": "Grok Video",
|
||||
"description": "Generate video from a prompt or an image",
|
||||
@@ -4132,6 +4198,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrokVideoReferenceNode": {
|
||||
"display_name": "Grok Reference-to-Video",
|
||||
"description": "Generate video guided by reference images as style and content references.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Text description of the desired video."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "The model to use for video generation."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_aspect_ratio": {
|
||||
"name": "aspect_ratio"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrowMask": {
|
||||
"display_name": "Grow Mask",
|
||||
"inputs": {
|
||||
@@ -7623,6 +7724,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVReferenceAudio": {
|
||||
"display_name": "LTXV Reference Audio (ID-LoRA)",
|
||||
"description": "Set reference audio for ID-LoRA speaker identity transfer. Encodes a reference audio clip into the conditioning and optionally patches the model with identity guidance (extra forward pass without reference, amplifying the speaker identity effect).",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"reference_audio": {
|
||||
"name": "reference_audio",
|
||||
"tooltip": "Reference audio clip whose speaker identity to transfer. ~5 seconds recommended (training duration). Shorter or longer clips may degrade voice identity transfer."
|
||||
},
|
||||
"audio_vae": {
|
||||
"name": "audio_vae",
|
||||
"tooltip": "LTXV Audio VAE for encoding."
|
||||
},
|
||||
"identity_guidance_scale": {
|
||||
"name": "identity_guidance_scale",
|
||||
"tooltip": "Strength of identity guidance. Runs an extra forward pass without reference each step to amplify speaker identity. Set to 0 to disable (no extra pass)."
|
||||
},
|
||||
"start_percent": {
|
||||
"name": "start_percent",
|
||||
"tooltip": "Start of the sigma range where identity guidance is active."
|
||||
},
|
||||
"end_percent": {
|
||||
"name": "end_percent",
|
||||
"tooltip": "End of the sigma range where identity guidance is active."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVScheduler": {
|
||||
"display_name": "LTXVScheduler",
|
||||
"inputs": {
|
||||
@@ -16076,6 +16225,10 @@
|
||||
"name": "lora_dtype",
|
||||
"tooltip": "The dtype to use for lora."
|
||||
},
|
||||
"quantized_backward": {
|
||||
"name": "quantized_backward",
|
||||
"tooltip": "When using training_dtype 'none' and training on quantized model, doing backward with quantized matmul when enabled."
|
||||
},
|
||||
"algorithm": {
|
||||
"name": "algorithm",
|
||||
"tooltip": "The algorithm to use for training."
|
||||
|
||||
@@ -564,19 +564,26 @@
|
||||
"uploadCover": "+ Subir una portada",
|
||||
"uploadProfilePicture": "+ Subir una foto de perfil",
|
||||
"uploadWorkflowButton": "Subir mi flujo de trabajo",
|
||||
"usernameError": "De 3 a 42 caracteres alfanuméricos en minúsculas y guiones, debe comenzar y terminar con una letra o número",
|
||||
"usernameLabel": "Tu nombre de usuario (requerido)",
|
||||
"usernamePlaceholder": "@"
|
||||
},
|
||||
"comfyHubPublish": {
|
||||
"additionalInfo": "Información adicional",
|
||||
"back": "Atrás",
|
||||
"createProfileCta": "Crear un perfil",
|
||||
"createProfileToPublish": "Crea un perfil para publicar en ComfyHub",
|
||||
"exampleImage": "Imagen de ejemplo {index}",
|
||||
"exampleImagePosition": "Imagen de ejemplo {index} de {total}",
|
||||
"examplesDescription": "Agrega hasta {total} imágenes de ejemplo adicionales",
|
||||
"maxExamples": "Puedes seleccionar hasta {max} ejemplos",
|
||||
"next": "Siguiente",
|
||||
"publishButton": "Publicar en ComfyHub",
|
||||
"publishFailedDescription": "Ocurrió un error al publicar tu flujo de trabajo. Por favor, inténtalo de nuevo.",
|
||||
"publishFailedTitle": "Error al publicar",
|
||||
"removeExampleImage": "Eliminar imagen de ejemplo",
|
||||
"selectAThumbnail": "Selecciona una miniatura",
|
||||
"shareAs": "Compartir como",
|
||||
"showLessTags": "Mostrar menos...",
|
||||
"showMoreTags": "Mostrar más...",
|
||||
"stepDescribe": "Describe tu flujo de trabajo",
|
||||
@@ -591,6 +598,7 @@
|
||||
"thumbnailPreview": "Vista previa de la miniatura",
|
||||
"thumbnailVideo": "Video",
|
||||
"title": "Publicar en ComfyHub",
|
||||
"unsavedDescription": "Debes guardar tu flujo de trabajo antes de publicarlo en ComfyHub. Guárdalo ahora para continuar.",
|
||||
"uploadAnImage": "Haz clic para buscar o arrastra una imagen",
|
||||
"uploadComparison": "Subir antes y después",
|
||||
"uploadComparisonAfterPrompt": "Después",
|
||||
@@ -606,13 +614,7 @@
|
||||
"workflowDescription": "Descripción del flujo de trabajo",
|
||||
"workflowDescriptionPlaceholder": "¿Qué hace que tu flujo de trabajo sea emocionante y especial? Sé específico para que las personas sepan qué esperar.",
|
||||
"workflowName": "Nombre del flujo de trabajo",
|
||||
"workflowNamePlaceholder": "Consejo: ingresa un nombre descriptivo y fácil de buscar",
|
||||
"workflowType": "Tipo de flujo de trabajo",
|
||||
"workflowTypeEditing": "Edición",
|
||||
"workflowTypeImageGeneration": "Generación de imágenes",
|
||||
"workflowTypePlaceholder": "Selecciona el tipo",
|
||||
"workflowTypeUpscaling": "Aumento de resolución",
|
||||
"workflowTypeVideoGeneration": "Generación de video"
|
||||
"workflowNamePlaceholder": "Consejo: ingresa un nombre descriptivo y fácil de buscar"
|
||||
},
|
||||
"commands": {
|
||||
"clear": "Limpiar flujo de trabajo",
|
||||
@@ -780,6 +782,7 @@
|
||||
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
|
||||
"CONDITIONING": "ACONDICIONAMIENTO",
|
||||
"CONTROL_NET": "RED_DE_CONTROL",
|
||||
"CURVE": "CURVA",
|
||||
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
|
||||
"FILE_3D": "ARCHIVO_3D",
|
||||
"FILE_3D_FBX": "ARCHIVO_3D_FBX",
|
||||
@@ -793,6 +796,7 @@
|
||||
"GEMINI_INPUT_FILES": "ARCHIVOS_ENTRADA_GEMINI",
|
||||
"GLIGEN": "GLIGEN",
|
||||
"GUIDER": "GUÍA",
|
||||
"HISTOGRAM": "HISTOGRAMA",
|
||||
"HOOKS": "GANCHOS",
|
||||
"HOOK_KEYFRAMES": "GANCHO_FOTOGRAMAS_CLAVE",
|
||||
"IMAGE": "IMAGEN",
|
||||
@@ -884,13 +888,13 @@
|
||||
"resume": "Reanudar descarga"
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"defaultTitle": "Ocurrió un error",
|
||||
"extensionFileHint": "Esto puede deberse al siguiente script",
|
||||
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
|
||||
"noStackTrace": "No hay seguimiento de pila disponible",
|
||||
"promptExecutionError": "La ejecución del prompt falló",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature."
|
||||
"promptExecutionError": "La ejecución del prompt falló"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERROR | {count} ERRORES",
|
||||
@@ -934,6 +938,19 @@
|
||||
"textToImage": "Texto a imagen",
|
||||
"textToVideo": "Texto a video"
|
||||
},
|
||||
"execution": {
|
||||
"decoding": "Decodificando…",
|
||||
"encoding": "Codificando…",
|
||||
"generating": "Generando…",
|
||||
"generatingVideo": "Generando video…",
|
||||
"loading": "Cargando…",
|
||||
"processing": "Procesando…",
|
||||
"processingVideo": "Procesando video…",
|
||||
"resizing": "Redimensionando…",
|
||||
"running": "Ejecutando…",
|
||||
"saving": "Guardando…",
|
||||
"training": "Entrenando…"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Todas las exportaciones completadas",
|
||||
"downloadExport": "Descargar exportación",
|
||||
@@ -1090,6 +1107,7 @@
|
||||
"icon": "Icono",
|
||||
"imageDoesNotExist": "La imagen no existe",
|
||||
"imageFailedToLoad": "Falló la carga de la imagen",
|
||||
"imageGallery": "galería de imágenes",
|
||||
"imageLightbox": "Vista previa de imagen",
|
||||
"imagePreview": "Vista previa de imagen - Usa las teclas de flecha para navegar entre imágenes",
|
||||
"imageUrl": "URL de la imagen",
|
||||
@@ -1104,7 +1122,6 @@
|
||||
"installed": "Instalado",
|
||||
"installing": "Instalando",
|
||||
"interrupted": "Interrumpido",
|
||||
"itemSelected": "{selectedCount} elemento seleccionado",
|
||||
"itemsCopiedToClipboard": "Elementos copiados al portapapeles",
|
||||
"itemsSelected": "{selectedCount} elementos seleccionados",
|
||||
"job": "Tarea",
|
||||
@@ -1310,6 +1327,7 @@
|
||||
"versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.",
|
||||
"videoFailedToLoad": "Falló la carga del video",
|
||||
"videoPreview": "Vista previa de video - Usa las teclas de flecha para navegar entre videos",
|
||||
"viewGrid": "Vista de cuadrícula",
|
||||
"viewImageOfTotal": "Ver imagen {index} de {total}",
|
||||
"viewVideoOfTotal": "Ver video {index} de {total}",
|
||||
"volume": "Volumen",
|
||||
|
||||