Compare commits

..

91 Commits

Author SHA1 Message Date
Jacob Segal
ada4f1d533 Add Playwright tests for async execution 2025-07-17 18:27:09 -07:00
Jacob Segal
aae20d9417 Merge remote-tracking branch 'origin/main' into js/async_nodes 2025-07-16 10:41:50 -07:00
guill
1200c07fcd Add support for Feature Flags (#4439) 2025-07-15 15:59:11 -07:00
Christian Byrne
be7edab141 [feat] Add GitHub Action for automated Claude PR reviews (#4452) 2025-07-14 16:44:51 -07:00
filtered
f3168aac89 Revert "[fix] Fix Danger CI permissions for PRs from forks" (#4450) 2025-07-15 04:52:01 +10:00
Benjamin Lu
2f3c762e85 [fix] Fix Danger CI permissions for PRs from forks (#4449)
It's good to have working and in ASAP, although better approaches are being researched and investigated
2025-07-14 14:08:44 -04:00
Benjamin Lu
8b8caa4b29 Add Danger PR Review (#4442) 2025-07-13 21:21:29 -07:00
Christian Byrne
a70d69cbd2 [fix] Sync subgraph node title changes with breadcrumb navigation (#4394) 2025-07-13 07:37:48 +10:00
Jacob Segal
9f4abbc3af Rename HierarchicalNodeId to NodeExecutionId 2025-07-11 19:04:48 -07:00
filtered
01c735d943 Fix cannot check widget value if undefined (#4433) 2025-07-12 09:05:58 +10:00
Comfy Org PR Bot
f0bc4c6959 [chore] Update litegraph to 0.16.9 (#4432)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-12 09:01:24 +10:00
Jacob Segal
0887bb6654 Address PR feedback 2025-07-11 16:01:09 -07:00
filtered
19eaf6ecdc Fix SubgraphNode widget values ignored (#4429) 2025-07-11 22:37:23 +10:00
Comfy Org PR Bot
054077c445 [chore] Update litegraph to 0.16.8 (#4427)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-11 22:28:02 +10:00
filtered
ef9b625208 Fix DTO return type to allow clean test/merge (#4426) 2025-07-11 19:27:31 +10:00
Comfy Org PR Bot
688193ad9a 1.24.1 (#4425)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-11 18:13:36 +10:00
filtered
5c119fcbda Improve execution logic / Fix group node execution (#4422) 2025-07-11 00:40:48 -07:00
Comfy Org PR Bot
998abbbdbd [chore] Update litegraph to 0.16.7 (#4424)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-11 00:22:05 -07:00
filtered
696c8720b6 [CI] Improve claude code release command (#4413) 2025-07-11 00:19:59 -07:00
ComfyUI Wiki
80e5cf1b9d Update template translation (#4396)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-10 23:10:42 -07:00
Deep Roy
7cf5d1e86b Add prompt ID to interrupt API call (#4393) 2025-07-10 17:24:21 -07:00
github-actions
868e047272 Update locales [skip ci] 2025-07-10 22:44:44 +00:00
Jacob Segal
abb82e5fd1 Merge remote-tracking branch 'upstream/main' into js/async_nodes 2025-07-10 15:35:08 -07:00
Emanuel F.
ab43b5e421 Menu bar mobile behavior change (#4312) 2025-07-10 15:08:51 -07:00
ComfyUI Wiki
43f73f8856 Correct the translation for 'Credits' in the zh-TW locale. (#4415) 2025-07-10 12:44:25 +00:00
filtered
0b5ade3a3b [chore] Update docs - node.js versions (#4414) 2025-07-10 05:34:28 -07:00
Christian Byrne
eb63b5c536 [feat] Add Traditional Chinese language support (#4410)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-10 01:52:47 -07:00
Christian Byrne
e6d78ab22c docs: Add Claude Code command to create release (#4408) 2025-07-09 22:53:07 -07:00
Christian Byrne
bc4753e119 [docs] Improve language contribution process (#4409) 2025-07-09 22:51:12 -07:00
Comfy Org PR Bot
733c9f81b0 1.24.0 (#4406)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-09 17:20:33 -07:00
filtered
1afae4f723 [CI] Fix frontend package release skipped (#4404) 2025-07-09 16:28:55 -07:00
bmcomfy
1632798fd2 [System Pop Up] Improve help center and what's new popup UI (#4395)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-09 16:17:50 -07:00
Christian Byrne
103139fdab 1.24.0 (#4401)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-09 15:00:20 -07:00
filtered
834ac3ea61 [CodeHealth] Simplify code as follow-up to #4354 (#4400) 2025-07-09 13:08:33 -07:00
Christian Byrne
22c70d5d1b [fix] use getter functions for sidebar tab command labels to resolve i18n collection issues (#4370)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-08 23:50:49 -07:00
bmcomfy
f5b03f323d [System Pop Up] Hide hidden and deprecated settings from search results (#4390) 2025-07-08 17:19:37 -07:00
bmcomfy
d6f6407c44 [System Pop Up] Add setting to disable version update notifications (#4388) 2025-07-08 14:43:11 -07:00
Jacob Segal
db70265e16 Fix some unit tests 2025-07-08 01:22:13 -07:00
github-actions
c8137f7f98 Update locales [skip ci] 2025-07-08 07:48:52 +00:00
Jacob Segal
132a9dbb5f Add a feature flags message to reduce bandwidth 2025-07-08 00:23:10 -07:00
Christian Byrne
2906ea3fd9 [fix] Correct API node pricing discrepancies (#4381) 2025-07-07 23:33:55 -07:00
Comfy Org PR Bot
c03771988d [chore] Update litegraph to 0.16.6 (#4380)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-07-07 23:16:09 -07:00
Christian Byrne
368c54bcf6 [docs] Add Claude Code command for automated hotfix releases (#4369) 2025-07-07 20:43:50 -07:00
Christian Byrne
f1575a693f [update] Video to Video API node pricing (#4378) 2025-07-07 20:24:33 -07:00
Jacob Segal
f076a1c422 Fix bug with browser tab title 2025-07-07 19:27:17 -07:00
Jacob Segal
f6c65d3fe7 Progress bars work in subgraphs 2025-07-07 17:16:38 -07:00
Jacob Segal
c8371d6089 Add functions for mapping node locations to an id
These locations differ from either local ids (like `5`) or execution ids
(like `3:4:5`) in that all nodes which are 'linked' such that changes to
one node will apply changes to other nodes will map to the same id. This
is specifically relevant for multiple instances of the same subgraph.

These IDs will actually get used in a followup commit
2025-07-06 20:41:58 -07:00
Christian Byrne
4eeff5533a [feat] Add dynamic pricing for new API nodes (#4367) 2025-07-06 18:31:04 -07:00
Alexander Piskun
c7877dbd18 fix(float-precision): correct float widget rounding (#4291)
Signed-off-by: Alexander Piskun <bigcat88@icloud.com>
2025-07-06 15:40:27 -07:00
Terry Jia
4cbcded820 add defaultsByInstallVersion (#4354) 2025-07-06 13:28:58 -07:00
Comfy Org PR Bot
469594e5cc [chore] Update litegraph to 0.16.5 (#4365)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-06 13:38:06 +00:00
Christian Byrne
191b4574b9 [fix] Add dynamic pricing for API nodes with quantity parameters (#4362) 2025-07-05 20:50:33 -07:00
filtered
0c4339f652 [TS] Update callbacks using CanvasMouseEvent #1104 (#4358) 2025-07-05 11:53:44 -07:00
RickyHuang
35556eb674 fIx: side toolbar tab tooltip not reactive when changing locale (#4213)
Co-authored-by: Huang Yun Qi <yun-qi.huang@ubisoft.com>
2025-07-05 02:54:23 -07:00
Christian Byrne
92b65ca00e [fix] Remove optional designation from issue report details field (#4355) 2025-07-04 20:34:47 -07:00
Christian Byrne
8f4e807468 [fix] move i18n pre-commit check inside Windows conditional block (#4353) 2025-07-04 17:25:04 -07:00
Terry Jia
c1db367422 add installedVersion (#4337)
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-07-04 16:52:18 -07:00
Christian Byrne
3b435e337e [fix] Add dynamic pricing for Ideogram nodes based on num_images parameter (#4351) 2025-07-04 16:13:33 -07:00
Terry Jia
ee5088551e Vue expose (#4265) 2025-07-03 21:35:24 -07:00
Christian Byrne
44bbfa9f39 [feat] Implement getNodeByComfyNodeName API integration (#4343) 2025-07-03 17:59:21 -07:00
Jacob Segal
a70a81c9ae Address PR feedback 2025-07-03 14:03:11 -07:00
Comfy Org PR Bot
1b4ad61e7f [chore] Update Comfy Registry API types from comfy-api@4b0dc99 (#4340)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-03 12:42:23 -07:00
Christian Byrne
7befec5b17 Add unused i18n keys detection to pre-commit hook (#4328) 2025-07-03 10:53:56 -07:00
Comfy Org PR Bot
a51c09893f 1.24.0-1 (#4336)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-02 19:52:05 -07:00
Comfy Org PR Bot
f290c00a61 [chore] Update litegraph to 0.16.4 (#4335)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-02 17:57:33 -07:00
bmcomfy
a45753486d [System Pop Up] Improve help center menu behavior and Electron compatibility (#4330) 2025-07-02 16:13:13 -07:00
Jacob Segal
aa5fa81824 Update the frontend to support async nodes. 2025-07-02 12:16:17 -07:00
Christian Byrne
5cc1a8dea2 [test] Add release notification browser tests (#4311)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-02 01:29:09 -07:00
Christian Byrne
959ab3b3ec [feat] Add ESLint i18n enforcement and fix hardcoded strings (#4327)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-02 00:41:46 -07:00
Terry Jia
35ff882ff2 [3d] better solution to support reading extra resource/texture (#4209) 2025-07-01 21:25:18 -07:00
filtered
f57f97cfcd [TS] Remove frontend-only typing from litegraph (#4325) 2025-07-01 20:07:05 -07:00
Christian Byrne
8f825c066b [docs] add code quality guidelines for i18n, async cleanup, and error handling (#4305) 2025-07-01 17:13:55 -07:00
Comfy Org PR Bot
e6f90e3101 1.24.0-0 (#4321)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-01 22:06:28 +00:00
filtered
d68391a80a [CI] Fix prerelease version tag not set (#4322) 2025-07-01 15:06:08 -07:00
filtered
df710945c9 [CI] Skip i18n in unrelated PRs (#4320) 2025-07-01 14:53:48 -07:00
filtered
8d6360074d Use prerelease flag for draft releases (#4319) 2025-07-01 14:51:24 -07:00
filtered
26c106c3e4 Allow prerelease using version bump action (#4318) 2025-07-01 14:29:55 -07:00
Comfy Org PR Bot
d92c282439 [chore] Update litegraph to 0.16.3 (#4316)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-01 13:40:21 -07:00
Comfy Org PR Bot
bf3dcc83a0 [chore] Update litegraph to 0.16.2 (#4315)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-01 12:58:53 -07:00
filtered
6470a0bbd9 [CodeHealth] Follow-up on #4288 - code style / async (#4308) 2025-07-01 09:46:24 -07:00
ComfyUI Wiki
c75015c5b8 Fix helper menu issues and align with the design. (#4261) 2025-06-30 21:36:09 -04:00
Christian Byrne
64a2a5b3ae [fix] Mock release API in browser tests to prevent UI interference (#4310) 2025-06-30 17:36:07 -07:00
filtered
fada8bf9cf Follow-up on #4256 (#4307) 2025-06-30 12:25:55 -07:00
Terry Jia
5bbed91295 usage log table (#4288)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-30 12:13:01 -07:00
Christian Byrne
eb8b67dd9d [docs] Update nested README files with comprehensive component listings (#4303) 2025-06-29 19:55:21 -07:00
Christian Byrne
d6a8f98327 [docs] add component communication best practices (#4302) 2025-06-29 18:25:28 -07:00
filtered
8457768a41 [Test] Update test expectations to match core changes to scheduler (#4293)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-28 21:58:31 -07:00
Comfy Org PR Bot
3ae7faa8c5 [chore] Update litegraph to 0.16.1 (#4292)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-28 17:08:56 -07:00
filtered
a7fb685290 Add Subgraphs (#3905)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-28 15:37:23 -07:00
Christian Byrne
7620bb9063 [bugfix] Handle backend error messages with appended content (#4283) 2025-06-27 13:47:21 -07:00
bmcomfy
2d2cec2e79 [System Pop Up] Add help center with release notifications and "What's New" popup (#4256)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-26 14:11:15 -07:00
186 changed files with 29845 additions and 2297 deletions

View File

@@ -0,0 +1,708 @@
# Comprehensive PR Review for ComfyUI Frontend
<task>
You are performing a comprehensive code review for PR #$1 in the ComfyUI frontend repository. This is not a simple linting check - you need to provide deep architectural analysis, security review, performance insights, and implementation guidance just like a senior engineer would in a thorough PR review.
Your review should cover:
1. Architecture and design patterns
2. Security vulnerabilities and risks
3. Performance implications
4. Code quality and maintainability
5. Integration with existing systems
6. Best practices and conventions
7. Testing considerations
8. Documentation needs
</task>
Arguments: PR number passed via PR_NUMBER environment variable
## Phase 0: Initialize Variables and Helper Functions
```bash
# Validate PR_NUMBER first thing
if [ -z "$PR_NUMBER" ]; then
echo "Error: PR_NUMBER environment variable is not set"
echo "Usage: PR_NUMBER=<number> claude run /comprehensive-pr-review"
exit 1
fi
# Initialize all counters at the start
CRITICAL_COUNT=0
HIGH_COUNT=0
MEDIUM_COUNT=0
LOW_COUNT=0
ARCHITECTURE_ISSUES=0
SECURITY_ISSUES=0
PERFORMANCE_ISSUES=0
QUALITY_ISSUES=0
# Helper function for posting review comments
post_review_comment() {
local file_path=$1
local line_number=$2
local severity=$3 # critical/high/medium/low
local category=$4 # architecture/security/performance/quality
local issue=$5
local context=$6
local suggestion=$7
# Update counters
case $severity in
"critical") ((CRITICAL_COUNT++)) ;;
"high") ((HIGH_COUNT++)) ;;
"medium") ((MEDIUM_COUNT++)) ;;
"low") ((LOW_COUNT++)) ;;
esac
case $category in
"architecture") ((ARCHITECTURE_ISSUES++)) ;;
"security") ((SECURITY_ISSUES++)) ;;
"performance") ((PERFORMANCE_ISSUES++)) ;;
"quality") ((QUALITY_ISSUES++)) ;;
esac
# Post inline comment via GitHub CLI
local comment="${issue}\n${context}\n${suggestion}"
gh pr review $PR_NUMBER --comment --body "$comment" -F - <<< "$comment"
}
```
## Phase 1: Environment Setup and PR Context
```bash
# Pre-flight checks
check_prerequisites() {
# Check gh CLI is available
if ! command -v gh &> /dev/null; then
echo "Error: gh CLI is not installed"
exit 1
fi
# In GitHub Actions, auth is handled via GITHUB_TOKEN
if [ -n "$GITHUB_ACTIONS" ] && [ -z "$GITHUB_TOKEN" ]; then
echo "Error: GITHUB_TOKEN is not set in GitHub Actions"
exit 1
fi
# Check if we're authenticated
if ! gh auth status &> /dev/null; then
echo "Error: Not authenticated with GitHub. Run 'gh auth login'"
exit 1
fi
# Set repository if not already set
if [ -z "$REPOSITORY" ]; then
REPOSITORY="Comfy-Org/ComfyUI_frontend"
fi
# Check PR exists and is open
PR_STATE=$(gh pr view $PR_NUMBER --repo $REPOSITORY --json state -q .state 2>/dev/null || echo "NOT_FOUND")
if [ "$PR_STATE" = "NOT_FOUND" ]; then
echo "Error: PR #$PR_NUMBER not found in $REPOSITORY"
exit 1
elif [ "$PR_STATE" != "OPEN" ]; then
echo "Error: PR #$PR_NUMBER is not open (state: $PR_STATE)"
exit 1
fi
# Check API rate limits
RATE_REMAINING=$(gh api /rate_limit --jq '.rate.remaining' 2>/dev/null || echo "5000")
if [ "$RATE_REMAINING" -lt 100 ]; then
echo "Warning: Low API rate limit: $RATE_REMAINING remaining"
if [ "$RATE_REMAINING" -lt 50 ]; then
echo "Error: Insufficient API rate limit for comprehensive review"
exit 1
fi
fi
echo "Pre-flight checks passed"
}
# Run pre-flight checks
check_prerequisites
echo "Starting comprehensive review of PR #$PR_NUMBER"
# Fetch PR information with error handling
echo "Fetching PR information..."
if ! gh pr view $PR_NUMBER --repo $REPOSITORY --json files,title,body,additions,deletions,baseRefName,headRefName > pr_info.json; then
echo "Error: Failed to fetch PR information"
exit 1
fi
# Extract branch names
BASE_BRANCH=$(jq -r '.baseRefName' < pr_info.json)
HEAD_BRANCH=$(jq -r '.headRefName' < pr_info.json)
# Checkout PR branch locally for better file inspection
echo "Checking out PR branch..."
git fetch origin "pull/$PR_NUMBER/head:pr-$PR_NUMBER"
git checkout "pr-$PR_NUMBER"
# Get changed files using git locally (much faster)
git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt
# Get the diff using git locally
git diff "origin/$BASE_BRANCH" > pr_diff.txt
# Get detailed file changes with line numbers
git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt
# For API compatibility, create a simplified pr_files.json
echo '[]' > pr_files.json
while IFS=$'\t' read -r status file; do
if [[ "$status" != "D" ]]; then # Skip deleted files
# Get the patch for this file
patch=$(git diff "origin/$BASE_BRANCH" -- "$file" | jq -Rs .)
additions=$(git diff --numstat "origin/$BASE_BRANCH" -- "$file" | awk '{print $1}')
deletions=$(git diff --numstat "origin/$BASE_BRANCH" -- "$file" | awk '{print $2}')
jq --arg file "$file" \
--arg patch "$patch" \
--arg additions "$additions" \
--arg deletions "$deletions" \
'. += [{
"filename": $file,
"patch": $patch,
"additions": ($additions | tonumber),
"deletions": ($deletions | tonumber)
}]' pr_files.json > pr_files.json.tmp
mv pr_files.json.tmp pr_files.json
fi
done < file_changes.txt
# Setup caching directory
CACHE_DIR=".claude-review-cache"
mkdir -p "$CACHE_DIR"
# Function to cache analysis results
cache_analysis() {
local file_path=$1
local analysis_result=$2
local file_hash=$(git hash-object "$file_path" 2>/dev/null || echo "no-hash")
if [ "$file_hash" != "no-hash" ]; then
echo "$analysis_result" > "$CACHE_DIR/${file_hash}.cache"
fi
}
# Function to get cached analysis
get_cached_analysis() {
local file_path=$1
local file_hash=$(git hash-object "$file_path" 2>/dev/null || echo "no-hash")
if [ "$file_hash" != "no-hash" ] && [ -f "$CACHE_DIR/${file_hash}.cache" ]; then
cat "$CACHE_DIR/${file_hash}.cache"
return 0
fi
return 1
}
# Clean old cache entries (older than 7 days)
find "$CACHE_DIR" -name "*.cache" -mtime +7 -delete 2>/dev/null || true
```
## Phase 2: Load Comprehensive Knowledge Base
```bash
# Don't create knowledge directory until we know we need it
KNOWLEDGE_FOUND=false
# Use local cache for knowledge base to avoid repeated downloads
KNOWLEDGE_CACHE_DIR=".claude-knowledge-cache"
mkdir -p "$KNOWLEDGE_CACHE_DIR"
# Option to use cloned prompt library for better performance
PROMPT_LIBRARY_PATH="../comfy-claude-prompt-library"
if [ -d "$PROMPT_LIBRARY_PATH" ]; then
echo "Using local prompt library at $PROMPT_LIBRARY_PATH"
USE_LOCAL_PROMPT_LIBRARY=true
else
echo "No local prompt library found, will use GitHub API"
USE_LOCAL_PROMPT_LIBRARY=false
fi
# Function to fetch with cache
fetch_with_cache() {
local url=$1
local output_file=$2
local cache_file="$KNOWLEDGE_CACHE_DIR/$(echo "$url" | sed 's/[^a-zA-Z0-9]/_/g')"
# Check if cached version exists and is less than 1 day old
if [ -f "$cache_file" ] && [ $(find "$cache_file" -mtime -1 2>/dev/null | wc -l) -gt 0 ]; then
# Create knowledge directory only when we actually have content
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
cp "$cache_file" "$output_file"
echo "Using cached version of $(basename "$output_file")"
return 0
fi
# Try to fetch fresh version
if curl -s -f "$url" > "$output_file.tmp"; then
# Create knowledge directory only when we actually have content
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
mv "$output_file.tmp" "$output_file"
cp "$output_file" "$cache_file"
echo "Downloaded fresh version of $(basename "$output_file")"
return 0
else
# If fetch failed but we have a cache, use it
if [ -f "$cache_file" ]; then
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
cp "$cache_file" "$output_file"
echo "Using stale cache for $(basename "$output_file") (download failed)"
return 0
fi
echo "Failed to load $(basename "$output_file")"
return 1
fi
}
# Load REPOSITORY_GUIDE.md for deep architectural understanding
echo "Loading ComfyUI Frontend repository guide..."
if [ "$USE_LOCAL_PROMPT_LIBRARY" = "true" ] && [ -f "$PROMPT_LIBRARY_PATH/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md" ]; then
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
cp "$PROMPT_LIBRARY_PATH/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md" "review_knowledge/repository_guide.md"
echo "Loaded repository guide from local prompt library"
else
fetch_with_cache "https://raw.githubusercontent.com/Comfy-Org/comfy-claude-prompt-library/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md" "review_knowledge/repository_guide.md"
fi
# 3. Discover and load relevant knowledge folders from GitHub API
echo "Discovering available knowledge folders..."
KNOWLEDGE_API_URL="https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/knowledge"
if KNOWLEDGE_FOLDERS=$(curl -s "$KNOWLEDGE_API_URL" | jq -r '.[] | select(.type=="dir") | .name' 2>/dev/null); then
echo "Available knowledge folders: $KNOWLEDGE_FOLDERS"
# Analyze changed files to determine which knowledge folders might be relevant
CHANGED_FILES=$(cat changed_files.txt)
PR_TITLE=$(jq -r '.title' < pr_info.json)
PR_BODY=$(jq -r '.body // ""' < pr_info.json)
# For each knowledge folder, check if it might be relevant to the PR
for folder in $KNOWLEDGE_FOLDERS; do
# Simple heuristic: if folder name appears in changed file paths or PR context
if echo "$CHANGED_FILES $PR_TITLE $PR_BODY" | grep -qi "$folder"; then
echo "Loading knowledge folder: $folder"
# Fetch all files in that knowledge folder
FOLDER_API_URL="https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/knowledge/$folder"
curl -s "$FOLDER_API_URL" | jq -r '.[] | select(.type=="file") | .download_url' 2>/dev/null | \
while read url; do
if [ -n "$url" ]; then
filename=$(basename "$url")
fetch_with_cache "$url" "review_knowledge/${folder}_${filename}"
fi
done
fi
done
else
echo "Could not discover knowledge folders"
fi
# 4. Load validation rules from the repository
echo "Loading validation rules..."
VALIDATION_API_URL="https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/commands/validation"
if VALIDATION_FILES=$(curl -s "$VALIDATION_API_URL" | jq -r '.[] | select(.name | contains("frontend") or contains("security") or contains("performance")) | .download_url' 2>/dev/null); then
for url in $VALIDATION_FILES; do
if [ -n "$url" ]; then
filename=$(basename "$url")
fetch_with_cache "$url" "review_knowledge/validation_${filename}"
fi
done
else
echo "Could not load validation rules"
fi
# 5. Load local project guidelines
if [ -f "CLAUDE.md" ]; then
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
cp CLAUDE.md review_knowledge/local_claude.md
fi
if [ -f ".github/CLAUDE.md" ]; then
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
cp .github/CLAUDE.md review_knowledge/github_claude.md
fi
```
## Phase 3: Deep Analysis Instructions
Perform a comprehensive analysis covering these areas:
### 3.1 Architectural Analysis
Based on the repository guide and project summary, evaluate:
- Does this change align with the established architecture patterns?
- Are domain boundaries respected?
- Is the extension system used appropriately?
- Are components properly organized by feature?
- Does it follow the established service/composable/store patterns?
### 3.2 Code Quality Beyond Linting
- Cyclomatic complexity and cognitive load
- SOLID principles adherence
- DRY violations that aren't caught by simple duplication checks
- Proper abstraction levels
- Interface design and API clarity
- No leftover debug code (console.log, commented code, TODO comments)
### 3.3 Library Usage Enforcement
CRITICAL: Never re-implement functionality that exists in our standard libraries:
- **Tailwind CSS**: Use utility classes instead of custom CSS or style attributes
- **PrimeVue**: Never re-implement components that exist in PrimeVue (buttons, modals, dropdowns, etc.)
- **VueUse**: Never re-implement composables that exist in VueUse (useLocalStorage, useDebounceFn, etc.)
- **Lodash**: Never re-implement utility functions (debounce, throttle, cloneDeep, etc.)
- **Common components**: Reuse components from src/components/common/
- **DOMPurify**: Always use for HTML sanitization
- **Fuse.js**: Use for fuzzy search functionality
- **Marked**: Use for markdown parsing
- **Pinia**: Use for global state management, not custom solutions
- **Zod**: Use for form validation with zodResolver pattern
- **Tiptap**: Use for rich text/markdown editing
- **Xterm.js**: Use for terminal emulation
- **Axios**: Use for HTTP client initialization
### 3.4 Security Deep Dive
Beyond obvious vulnerabilities:
- Authentication/authorization implications
- Data validation completeness
- State management security
- Cross-origin concerns
- Extension security boundaries
### 3.5 Performance Analysis
- Render performance implications
- Layout thrashing prevention
- Memory leak potential
- Network request optimization
- State management efficiency
### 3.6 Integration Concerns
- Breaking changes to internal APIs
- Extension compatibility
- Backward compatibility
- Migration requirements
## Phase 4: Create Detailed Review Comments
CRITICAL: Keep comments extremely concise and effective. Use only as many words as absolutely necessary.
- NO markdown formatting (no #, ##, ###, **, etc.)
- NO emojis
- Get to the point immediately
- Burden the reader as little as possible
For each issue found, create a concise inline comment with:
1. What's wrong (one line)
2. Why it matters (one line)
3. How to fix it (one line)
4. Code example only if essential
```bash
# Helper function for comprehensive comments
post_review_comment() {
local file_path=$1
local line_number=$2
local severity=$3 # critical/high/medium/low
local category=$4 # architecture/security/performance/quality
local issue=$5
local context=$6
local suggestion=$7
local example=$8
local body="### [$category] $severity Priority
**Issue**: $issue
**Context**: $context
**Suggestion**: $suggestion"
if [ -n "$example" ]; then
body="$body
**Example**:
\`\`\`typescript
$example
\`\`\`"
fi
body="$body
*Related: See [repository guide](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md) for patterns*"
gh api -X POST /repos/$REPOSITORY/pulls/$PR_NUMBER/comments \
-f path="$file_path" \
-f line=$line_number \
-f body="$body" \
-f commit_id="$COMMIT_SHA" \
-f side='RIGHT' || echo "Failed to post comment at $file_path:$line_number"
}
```
## Phase 5: Validation Rules Application
Apply ALL validation rules from the loaded knowledge, but focus on the changed lines:
### From Frontend Standards
- Vue 3 Composition API patterns
- Component communication patterns
- Proper use of composables
- TypeScript strict mode compliance
- Bundle optimization
### From Security Audit
- Input validation
- XSS prevention
- CSRF protection
- Secure state management
- API security
### From Performance Check
- Render optimization
- Memory management
- Network efficiency
- Bundle size impact
## Phase 6: Contextual Review Based on PR Type
Analyze the PR description and changes to determine the type:
```bash
# Extract PR metadata with error handling
if [ ! -f pr_info.json ]; then
echo "Error: pr_info.json not found"
exit 1
fi
PR_TITLE=$(jq -r '.title // "Unknown"' < pr_info.json)
PR_BODY=$(jq -r '.body // ""' < pr_info.json)
FILE_COUNT=$(wc -l < changed_files.txt)
ADDITIONS=$(jq -r '.additions // 0' < pr_info.json)
DELETIONS=$(jq -r '.deletions // 0' < pr_info.json)
# Determine PR type and apply specific review criteria
if echo "$PR_TITLE $PR_BODY" | grep -qiE "(feature|feat)"; then
echo "Detected feature PR - applying feature review criteria"
# Check for tests, documentation, backward compatibility
elif echo "$PR_TITLE $PR_BODY" | grep -qiE "(fix|bug)"; then
echo "Detected bug fix - checking root cause and regression tests"
# Verify fix addresses root cause, includes tests
elif echo "$PR_TITLE $PR_BODY" | grep -qiE "(refactor)"; then
echo "Detected refactoring - ensuring behavior preservation"
# Check that tests still pass, no behavior changes
fi
```
## Phase 7: Generate Comprehensive Summary
After all inline comments, create a detailed summary:
```bash
# Initialize metrics tracking
REVIEW_START_TIME=$(date +%s)
# Create the comprehensive summary
gh pr review $PR_NUMBER --comment --body "# Comprehensive PR Review
This review is generated by Claude. It may not always be accurate, as with human reviewers. If you believe that any of the comments are invalid or incorrect, please state why for each. For others, please implement the changes in one way or another.
## Review Summary
**PR**: $PR_TITLE (#$PR_NUMBER)
**Impact**: $ADDITIONS additions, $DELETIONS deletions across $FILE_COUNT files
### Issue Distribution
- Critical: $CRITICAL_COUNT
- High: $HIGH_COUNT
- Medium: $MEDIUM_COUNT
- Low: $LOW_COUNT
### Category Breakdown
- Architecture: $ARCHITECTURE_ISSUES issues
- Security: $SECURITY_ISSUES issues
- Performance: $PERFORMANCE_ISSUES issues
- Code Quality: $QUALITY_ISSUES issues
## Key Findings
### Architecture & Design
[Detailed architectural analysis based on repository patterns]
### Security Considerations
[Security implications beyond basic vulnerabilities]
### Performance Impact
[Performance analysis including bundle size, render impact]
### Integration Points
[How this affects other systems, extensions, etc.]
## Positive Observations
[What was done well, good patterns followed]
## References
- [Repository Architecture Guide](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md)
- [Frontend Standards](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/.claude/commands/validation/frontend-code-standards.md)
- [Security Guidelines](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/.claude/commands/validation/security-audit.md)
## Next Steps
1. Address critical issues before merge
2. Consider architectural feedback for long-term maintainability
3. Add tests for uncovered scenarios
4. Update documentation if needed
---
*This is a comprehensive automated review. For architectural decisions requiring human judgment, please request additional manual review.*"
```
## Important: Think Deeply
When reviewing:
1. **Think hard** about architectural implications
2. Consider how changes affect the entire system
3. Look for subtle bugs and edge cases
4. Evaluate maintainability over time
5. Consider extension developer experience
6. Think about migration paths
This is a COMPREHENSIVE review, not a linting pass. Provide the same quality feedback a senior engineer would give after careful consideration.
## Phase 8: Track Review Metrics
After completing the review, save metrics for analysis:
```bash
# Calculate review duration
REVIEW_END_TIME=$(date +%s)
REVIEW_DURATION=$((REVIEW_END_TIME - REVIEW_START_TIME))
# Calculate total issues
TOTAL_ISSUES=$((CRITICAL_COUNT + HIGH_COUNT + MEDIUM_COUNT + LOW_COUNT))
# Create metrics directory if it doesn't exist
METRICS_DIR=".claude/review-metrics"
mkdir -p "$METRICS_DIR"
# Generate metrics file
METRICS_FILE="$METRICS_DIR/metrics-$(date +%Y%m).json"
# Create or update monthly metrics file
if [ -f "$METRICS_FILE" ]; then
# Append to existing file
jq -n \
--arg pr "$PR_NUMBER" \
--arg title "$PR_TITLE" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg duration "$REVIEW_DURATION" \
--arg files "$FILE_COUNT" \
--arg additions "$ADDITIONS" \
--arg deletions "$DELETIONS" \
--arg total "$TOTAL_ISSUES" \
--arg critical "$CRITICAL_COUNT" \
--arg high "$HIGH_COUNT" \
--arg medium "$MEDIUM_COUNT" \
--arg low "$LOW_COUNT" \
--arg architecture "$ARCHITECTURE_ISSUES" \
--arg security "$SECURITY_ISSUES" \
--arg performance "$PERFORMANCE_ISSUES" \
--arg quality "$QUALITY_ISSUES" \
'{
pr_number: $pr,
pr_title: $title,
timestamp: $timestamp,
review_duration_seconds: ($duration | tonumber),
files_reviewed: ($files | tonumber),
lines_added: ($additions | tonumber),
lines_deleted: ($deletions | tonumber),
issues: {
total: ($total | tonumber),
by_severity: {
critical: ($critical | tonumber),
high: ($high | tonumber),
medium: ($medium | tonumber),
low: ($low | tonumber)
},
by_category: {
architecture: ($architecture | tonumber),
security: ($security | tonumber),
performance: ($performance | tonumber),
quality: ($quality | tonumber)
}
}
}' > "$METRICS_FILE.new"
# Merge with existing data
jq -s '.[0] + [.[1]]' "$METRICS_FILE" "$METRICS_FILE.new" > "$METRICS_FILE.tmp"
mv "$METRICS_FILE.tmp" "$METRICS_FILE"
rm "$METRICS_FILE.new"
else
# Create new file
jq -n \
--arg pr "$PR_NUMBER" \
--arg title "$PR_TITLE" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg duration "$REVIEW_DURATION" \
--arg files "$FILE_COUNT" \
--arg additions "$ADDITIONS" \
--arg deletions "$DELETIONS" \
--arg total "$TOTAL_ISSUES" \
--arg critical "$CRITICAL_COUNT" \
--arg high "$HIGH_COUNT" \
--arg medium "$MEDIUM_COUNT" \
--arg low "$LOW_COUNT" \
--arg architecture "$ARCHITECTURE_ISSUES" \
--arg security "$SECURITY_ISSUES" \
--arg performance "$PERFORMANCE_ISSUES" \
--arg quality "$QUALITY_ISSUES" \
'[{
pr_number: $pr,
pr_title: $title,
timestamp: $timestamp,
review_duration_seconds: ($duration | tonumber),
files_reviewed: ($files | tonumber),
lines_added: ($additions | tonumber),
lines_deleted: ($deletions | tonumber),
issues: {
total: ($total | tonumber),
by_severity: {
critical: ($critical | tonumber),
high: ($high | tonumber),
medium: ($medium | tonumber),
low: ($low | tonumber)
},
by_category: {
architecture: ($architecture | tonumber),
security: ($security | tonumber),
performance: ($performance | tonumber),
quality: ($quality | tonumber)
}
}
}]' > "$METRICS_FILE"
fi
echo "Review metrics saved to $METRICS_FILE"
```
This creates monthly metrics files (e.g., `metrics-202407.json`) that track:
- Which PRs were reviewed
- How long reviews took
- Types and severity of issues found
- Trends over time
You can later analyze these to see patterns and improve your development process.

View File

@@ -0,0 +1,625 @@
# Create Frontend Release
This command guides you through creating a comprehensive frontend release with semantic versioning analysis, automated change detection, security scanning, and multi-stage human verification.
<task>
Create a frontend release with version type: $ARGUMENTS
Expected format: Version increment type and optional description
Examples:
- `patch` - Bug fixes only
- `minor` - New features, backward compatible
- `major` - Breaking changes
- `prerelease` - Alpha/beta/rc releases
- `patch "Critical security fixes"` - With custom description
- `minor --skip-changelog` - Skip automated changelog generation
- `minor --dry-run` - Simulate release without executing
If no arguments provided, the command will always perform prerelease if the current version is prerelease, or patch in other cases. This command will never perform minor or major releases without explicit direction.
</task>
## Prerequisites
Before starting, ensure:
- You have push access to the repository
- GitHub CLI (`gh`) is authenticated
- You're on a clean main branch working tree
- All intended changes are merged to main
- You understand the scope of changes being released
## Critical Checks Before Starting
### 1. Check Current Version Status
```bash
# Get current version and check if it's a pre-release
CURRENT_VERSION=$(node -p "require('./package.json').version")
if [[ "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then
echo "⚠️ Current version $CURRENT_VERSION is a pre-release"
echo "Consider releasing stable (e.g., 1.24.0-1 → 1.24.0) first"
fi
```
### 2. Find Last Stable Release
```bash
# Get last stable release tag (no pre-release suffix)
LAST_STABLE=$(git tag -l "v*" | grep -v "\-" | sort -V | tail -1)
echo "Last stable release: $LAST_STABLE"
```
## Configuration Options
**Environment Variables:**
- `RELEASE_SKIP_SECURITY_SCAN=true` - Skip security audit
- `RELEASE_AUTO_APPROVE=true` - Skip some confirmation prompts
- `RELEASE_DRY_RUN=true` - Simulate release without executing
## Release Process
### Step 1: Environment Safety Check
1. Verify clean working directory:
```bash
git status --porcelain
```
2. Confirm on main branch:
```bash
git branch --show-current
```
3. Pull latest changes:
```bash
git pull origin main
```
4. Check GitHub CLI authentication:
```bash
gh auth status
```
5. Verify npm/PyPI publishing access (dry run)
6. **CONFIRMATION REQUIRED**: Environment ready for release?
### Step 2: Analyze Recent Changes
1. Get current version from package.json
2. **IMPORTANT**: Determine correct base for comparison:
```bash
# If current version is pre-release, use last stable release
if [[ "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then
BASE_TAG=$LAST_STABLE
else
BASE_TAG=$(git describe --tags --abbrev=0)
fi
```
3. Find commits since base release (CRITICAL: use --first-parent):
```bash
git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent
```
4. Count total commits:
```bash
COMMIT_COUNT=$(git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent | wc -l)
echo "Found $COMMIT_COUNT commits since $BASE_TAG"
```
5. Analyze commits for:
- Breaking changes (BREAKING CHANGE, !, feat())
- New features (feat:, feature:)
- Bug fixes (fix:, bugfix:)
- Documentation changes (docs:)
- Dependency updates
6. **VERIFY PR TARGET BRANCHES**:
```bash
# Get merged PRs and verify they were merged to main
gh pr list --state merged --limit 50 --json number,title,baseRefName,mergedAt | \
jq -r '.[] | select(.baseRefName == "main") | "\(.number): \(.title)"'
```
7. **HUMAN ANALYSIS**: Review change summary and verify scope
### Step 3: Version Preview
**Version Preview:**
- Current: `${CURRENT_VERSION}`
- Proposed: Show exact version number
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
### Step 4: Security and Dependency Audit
1. Run security audit:
```bash
npm audit --audit-level moderate
```
2. Check for known vulnerabilities in dependencies
3. Scan for hardcoded secrets or credentials:
```bash
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
```
4. Verify no sensitive data in recent commits
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
### Step 5: Pre-Release Testing
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
npm run test:browser
```
2. Run type checking:
```bash
npm run typecheck
```
3. Run linting (may have issues with missing packages):
```bash
npm run lint || echo "Lint issues - verify if critical"
```
4. Test build process:
```bash
npm run build
npm run build:types
```
5. **QUALITY GATE**: All tests and builds passing?
### Step 6: Breaking Change Analysis
1. Analyze API changes in:
- Public TypeScript interfaces
- Extension APIs
- Component props
- CLAUDE.md guidelines
2. Check for:
- Removed public functions/classes
- Changed function signatures
- Deprecated feature removals
- Configuration changes
3. Generate breaking change summary
4. **COMPATIBILITY REVIEW**: Breaking changes documented and justified?
### Step 7: Generate and Save Changelog
1. Extract commit messages since base release:
```bash
git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent > commits.txt
```
2. **CRITICAL**: Verify PR inclusion by checking merge location:
```bash
# For each significant PR mentioned, verify it's on main
for PR in ${SIGNIFICANT_PRS}; do
COMMIT=$(gh pr view $PR --json mergeCommit -q .mergeCommit.oid)
git branch -r --contains $COMMIT | grep -q "origin/main" || \
echo "WARNING: PR #$PR not on main branch!"
done
```
3. Group by type:
- 🚀 **Features** (feat:)
- 🐛 **Bug Fixes** (fix:)
- 💥 **Breaking Changes** (BREAKING CHANGE)
- 📚 **Documentation** (docs:)
- 🔧 **Maintenance** (chore:, refactor:)
- ⬆️ **Dependencies** (deps:, dependency updates)
4. Include PR numbers and links
5. Add issue references (Fixes #123)
6. **Save changelog locally:**
```bash
# Save to dated file for history
echo "$CHANGELOG" > release-notes-${NEW_VERSION}-$(date +%Y%m%d).md
# Save to current for easy access
echo "$CHANGELOG" > CURRENT_RELEASE_NOTES.md
```
7. **CHANGELOG REVIEW**: Verify all PRs listed are actually on main branch
### Step 8: Create Enhanced Release Notes
1. Create comprehensive user-facing release notes including:
- **What's New**: Major features and improvements
- **Bug Fixes**: User-visible fixes
- **Breaking Changes**: Migration guide if applicable
- **Dependencies**: Major dependency updates
- **Performance**: Notable performance improvements
- **Contributors**: Thank contributors for their work
2. Reference related documentation updates
3. Include screenshots for UI changes (if available)
4. **Save release notes:**
```bash
# Enhanced release notes for GitHub
echo "$RELEASE_NOTES" > github-release-notes-${NEW_VERSION}.md
```
5. **CONTENT REVIEW**: Release notes clear and helpful for users?
### Step 9: Create Version Bump PR
**For standard version bumps (patch/minor/major):**
```bash
# Trigger the workflow
gh workflow run version-bump.yaml -f version_type=${VERSION_TYPE}
# Workflow runs quickly - usually creates PR within 30 seconds
echo "Workflow triggered. Waiting for PR creation..."
```
**For releasing a stable version:**
1. Must manually create branch and update version:
```bash
git checkout -b version-bump-${NEW_VERSION}
# Edit package.json to remove pre-release suffix
git add package.json
git commit -m "${NEW_VERSION}"
git push origin version-bump-${NEW_VERSION}
```
2. Wait for PR creation (if using workflow) or create manually:
```bash
# For workflow-created PRs - wait and find it
sleep 30
# Look for PR from comfy-pr-bot (not github-actions)
PR_NUMBER=$(gh pr list --author comfy-pr-bot --limit 1 --json number --jq '.[0].number')
# Verify we got the PR
if [ -z "$PR_NUMBER" ]; then
echo "PR not found yet. Checking recent PRs..."
gh pr list --limit 5 --json number,title,author
fi
# For manual PRs
gh pr create --title "${NEW_VERSION}" \
--body-file enhanced-pr-description.md \
--label "Release"
```
3. **Create enhanced PR description:**
```bash
cat > enhanced-pr-description.md << EOF
# Release v${NEW_VERSION}
## Version Change
\`${CURRENT_VERSION}\` → \`${NEW_VERSION}\` (${VERSION_TYPE})
## Changelog
${CHANGELOG}
## Breaking Changes
${BREAKING_CHANGES}
## Testing Performed
- ✅ Full test suite (unit, component, browser)
- ✅ TypeScript compilation
- ✅ Linting checks
- ✅ Build verification
- ✅ Security audit
## Distribution Channels
- GitHub Release (with dist.zip)
- PyPI Package (comfyui-frontend-package)
- npm Package (@comfyorg/comfyui-frontend-types)
## Post-Release Tasks
- [ ] Verify all distribution channels
- [ ] Update external documentation
- [ ] Monitor for issues
EOF
```
4. Update PR with enhanced description:
```bash
gh pr edit ${PR_NUMBER} --body-file enhanced-pr-description.md
```
5. Add changelog as comment for easy reference:
```bash
gh pr comment ${PR_NUMBER} --body-file CURRENT_RELEASE_NOTES.md
```
6. **PR REVIEW**: Version bump PR created and enhanced correctly?
### Step 11: Critical Release PR Verification
1. **CRITICAL**: Verify PR has "Release" label:
```bash
gh pr view ${PR_NUMBER} --json labels | jq -r '.labels[].name' | grep -q "Release" || \
echo "ERROR: Release label missing! Add it immediately!"
```
2. Check for update-locales commits:
```bash
# WARNING: update-locales may add [skip ci] which blocks release workflow!
gh pr view ${PR_NUMBER} --json commits | grep -q "skip ci" && \
echo "WARNING: [skip ci] detected - release workflow may not trigger!"
```
3. Verify version number in package.json
4. Review all changed files
5. Ensure no unintended changes included
6. Wait for required PR checks:
```bash
gh pr checks ${PR_NUMBER} --watch
```
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
### Step 12: Pre-Merge Validation
1. **Review Requirements**: Release PRs require approval
2. Monitor CI checks - watch for update-locales
3. **CRITICAL WARNING**: If update-locales adds [skip ci], the release workflow won't trigger!
4. Check no new commits to main since PR creation
5. **DEPLOYMENT READINESS**: Ready to merge?
### Step 13: Execute Release
1. **FINAL CONFIRMATION**: Merge PR to trigger release?
2. Merge the Release PR:
```bash
gh pr merge ${PR_NUMBER} --merge
```
3. **IMMEDIATELY CHECK**: Did release workflow trigger?
```bash
sleep 10
gh run list --workflow=release.yaml --limit=1
```
4. If workflow didn't trigger due to [skip ci]:
```bash
echo "ERROR: Release workflow didn't trigger!"
echo "Options:"
echo "1. Create patch release (e.g., 1.24.1) to trigger workflow"
echo "2. Investigate manual release options"
```
5. If workflow triggered, monitor execution:
```bash
WORKFLOW_RUN_ID=$(gh run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[0].databaseId')
gh run watch ${WORKFLOW_RUN_ID}
```
### Step 14: Enhance GitHub Release
1. Wait for automatic release creation:
```bash
# Wait for release to be created
while ! gh release view v${NEW_VERSION} >/dev/null 2>&1; do
echo "Waiting for release creation..."
sleep 10
done
```
2. **Enhance the GitHub release:**
```bash
# Update release with our enhanced notes
gh release edit v${NEW_VERSION} \
--title "🚀 ComfyUI Frontend v${NEW_VERSION}" \
--notes-file github-release-notes-${NEW_VERSION}.md \
--latest
# Add any additional assets if needed
# gh release upload v${NEW_VERSION} additional-assets.zip
```
3. **Verify release details:**
```bash
gh release view v${NEW_VERSION}
```
### Step 15: Verify Multi-Channel Distribution
1. **GitHub Release:**
```bash
gh release view v${NEW_VERSION} --json assets,body,createdAt,tagName
```
- ✅ Check release notes
- ✅ Verify dist.zip attachment
- ✅ Confirm release marked as latest (for main branch)
2. **PyPI Package:**
```bash
# Check PyPI availability (may take a few minutes)
for i in {1..10}; do
if curl -s https://pypi.org/pypi/comfyui-frontend-package/json | jq -r '.releases | keys[]' | grep -q ${NEW_VERSION}; then
echo "✅ PyPI package available"
break
fi
echo "⏳ Waiting for PyPI package... (attempt $i/10)"
sleep 30
done
```
3. **npm Package:**
```bash
# Check npm availability
for i in {1..10}; do
if npm view @comfyorg/comfyui-frontend-types@${NEW_VERSION} version >/dev/null 2>&1; then
echo "✅ npm package available"
break
fi
echo "⏳ Waiting for npm package... (attempt $i/10)"
sleep 30
done
```
4. **DISTRIBUTION VERIFICATION**: All channels published successfully?
### Step 16: Post-Release Monitoring Setup
1. **Monitor immediate release health:**
```bash
# Check for immediate issues
gh issue list --label "bug" --state open --limit 5 --json title,number,createdAt
# Monitor download metrics (if accessible)
gh release view v${NEW_VERSION} --json assets --jq '.assets[].downloadCount'
```
2. **Update documentation tracking:**
```bash
cat > post-release-checklist.md << EOF
# Post-Release Checklist for v${NEW_VERSION}
## Immediate Tasks (Next 24 hours)
- [ ] Monitor error rates and user feedback
- [ ] Watch for critical issues
- [ ] Verify documentation is up to date
- [ ] Check community channels for questions
## Short-term Tasks (Next week)
- [ ] Update external integration guides
- [ ] Monitor adoption metrics
- [ ] Gather user feedback
- [ ] Plan next release cycle
## Long-term Tasks
- [ ] Analyze release process improvements
- [ ] Update release templates based on learnings
- [ ] Document any new patterns discovered
## Key Metrics to Track
- Download counts: GitHub, PyPI, npm
- Issue reports related to v${NEW_VERSION}
- Community feedback and adoption
- Performance impact measurements
EOF
```
3. **Create release summary:**
```bash
cat > release-summary-${NEW_VERSION}.md << EOF
# Release Summary: ComfyUI Frontend v${NEW_VERSION}
**Released:** $(date)
**Type:** ${VERSION_TYPE}
**Duration:** ~${RELEASE_DURATION} minutes
**Release Commit:** ${RELEASE_COMMIT}
## Metrics
- **Commits Included:** ${COMMITS_COUNT}
- **Contributors:** ${CONTRIBUTORS_COUNT}
- **Files Changed:** ${FILES_CHANGED}
- **Lines Added/Removed:** +${LINES_ADDED}/-${LINES_REMOVED}
## Distribution Status
- ✅ GitHub Release: Published
- ✅ PyPI Package: Available
- ✅ npm Types: Available
## Next Steps
- Monitor for 24-48 hours
- Address any critical issues immediately
- Plan next release cycle
## Files Generated
- \`release-notes-${NEW_VERSION}-$(date +%Y%m%d).md\` - Detailed changelog
- \`github-release-notes-${NEW_VERSION}.md\` - GitHub release notes
- \`post-release-checklist.md\` - Follow-up tasks
EOF
```
4. **RELEASE COMPLETION**: All post-release setup completed?
## Advanced Safety Features
### Rollback Procedures
**Pre-Merge Rollback:**
```bash
# Close version bump PR and reset
gh pr close ${PR_NUMBER}
git reset --hard origin/main
git clean -fd
```
**Post-Merge Rollback:**
```bash
# Create immediate patch release with reverts
git revert ${RELEASE_COMMIT}
# Follow this command again with patch version
```
**Emergency Procedures:**
```bash
# Document incident
cat > release-incident-${NEW_VERSION}.md << EOF
# Release Incident Report
**Version:** ${NEW_VERSION}
**Issue:** [Describe the problem]
**Impact:** [Severity and scope]
**Resolution:** [Steps taken]
**Prevention:** [Future improvements]
EOF
# Contact package registries for critical issues
echo "For critical security issues, consider:"
echo "- PyPI: Contact support for package yanking"
echo "- npm: Use 'npm unpublish' within 72 hours"
echo "- GitHub: Update release with warning notes"
```
### Quality Gates Summary
The command implements multiple quality gates:
1. **🔒 Security Gate**: Vulnerability scanning, secret detection
2. **🧪 Quality Gate**: Full test suite, linting, type checking
3. **📋 Content Gate**: Changelog accuracy, release notes quality
4. **🔄 Process Gate**: Release timing verification
5. **✅ Verification Gate**: Multi-channel publishing confirmation
6. **📊 Monitoring Gate**: Post-release health tracking
## Common Scenarios
### Scenario 1: Regular Feature Release
```bash
/project:create-frontend-release minor
```
- Analyzes features since last release
- Generates changelog automatically
- Creates comprehensive release notes
### Scenario 2: Critical Security Patch
```bash
/project:create-frontend-release patch "Security fixes for CVE-2024-XXXX"
```
- Expedited security scanning
- Enhanced monitoring setup
### Scenario 3: Major Version with Breaking Changes
```bash
/project:create-frontend-release major
```
- Comprehensive breaking change analysis
- Migration guide generation
### Scenario 4: Pre-release Testing
```bash
/project:create-frontend-release prerelease
```
- Creates alpha/beta/rc versions
- Draft release status
- Python package specs require that prereleases use alpha/beta/rc as the preid
## Common Issues and Solutions
### Issue: Pre-release Version Confusion
**Problem**: Not sure whether to promote pre-release or create new version
**Solution**:
- Follow semver standards: a prerelease version is followed by a normal release. It should have the same major, minor, and patch versions as the prerelease.
### Issue: Wrong Commit Count
**Problem**: Changelog includes commits from other branches
**Solution**: Always use `--first-parent` flag with git log
**Update**: Sometimes update-locales doesn't add [skip ci] - always verify!
### Issue: Missing PRs in Changelog
**Problem**: PR was merged to different branch
**Solution**: Verify PR merge target with:
```bash
gh pr view ${PR_NUMBER} --json baseRefName
```
### Issue: Release Failed Due to [skip ci]
**Problem**: Release workflow didn't trigger after merge
**Prevention**: Always avoid this scenario
- Ensure that `[skip ci]` or similar flags are NOT in the `HEAD` commit message of the PR
- Push a new, empty commit to the PR
- Always double-check this immediately before merging
**Recovery Strategy**:
1. Revert version in a new PR (e.g., 1.24.0 → 1.24.0-1)
2. Merge the revert PR
3. Run version bump workflow again
4. This creates a fresh PR without [skip ci]
Benefits: Cleaner than creating extra version numbers
## Key Learnings & Notes
1. **PR Author**: Version bump PRs are created by `comfy-pr-bot`, not `github-actions`
2. **Workflow Speed**: Version bump workflow typically completes in ~20-30 seconds
3. **Update-locales Behavior**: Inconsistent - sometimes adds [skip ci], sometimes doesn't
4. **Recovery Options**: Reverting version is cleaner than creating extra versions

View File

@@ -0,0 +1,222 @@
# Create Hotfix Release
This command guides you through creating a patch/hotfix release for ComfyUI Frontend with comprehensive safety checks and human confirmations at each step.
<task>
Create a hotfix release by cherry-picking commits or PR commits from main to a core branch: $ARGUMENTS
Expected format: Comma-separated list of commits or PR numbers
Examples:
- `abc123,def456,ghi789` (commits)
- `#1234,#5678` (PRs)
- `abc123,#1234,def456` (mixed)
If no arguments provided, the command will help identify the correct core branch and guide you through selecting commits/PRs.
</task>
## Prerequisites
Before starting, ensure:
- You have push access to the repository
- GitHub CLI (`gh`) is authenticated
- You're on a clean working tree
- You understand the commits/PRs you're cherry-picking
## Hotfix Release Process
### Step 1: Identify Target Core Branch
1. Fetch the current ComfyUI requirements.txt from master branch:
```bash
curl -s https://raw.githubusercontent.com/comfyanonymous/ComfyUI/master/requirements.txt | grep "comfyui-frontend-package"
```
2. Extract the `comfyui-frontend-package` version (e.g., `comfyui-frontend-package==1.23.4`)
3. Parse version to get major.minor (e.g., `1.23.4` → `1.23`)
4. Determine core branch: `core/<major>.<minor>` (e.g., `core/1.23`)
5. Verify the core branch exists: `git ls-remote origin refs/heads/core/*`
6. **CONFIRMATION REQUIRED**: Is `core/X.Y` the correct target branch?
### Step 2: Parse and Validate Arguments
1. Parse the comma-separated list of commits/PRs
2. For each item:
- If starts with `#`: Treat as PR number
- Otherwise: Treat as commit hash
3. For PR numbers:
- Fetch PR details using `gh pr view <number>`
- Extract the merge commit if PR is merged
- If PR has multiple commits, list them all
- **CONFIRMATION REQUIRED**: Use merge commit or cherry-pick individual commits?
4. Validate all commit hashes exist in the repository
### Step 3: Analyze Target Changes
1. For each commit/PR to cherry-pick:
- Display commit hash, author, date
- Show PR title and number (if applicable)
- Display commit message
- Show files changed and diff statistics
- Check if already in core branch: `git branch --contains <commit>`
2. Identify potential conflicts by checking changed files
3. **CONFIRMATION REQUIRED**: Proceed with these commits?
### Step 4: Create Hotfix Branch
1. Checkout the core branch (e.g., `core/1.23`)
2. Pull latest changes: `git pull origin core/X.Y`
3. Display current version from package.json
4. Create hotfix branch: `hotfix/<version>-<timestamp>`
- Example: `hotfix/1.23.4-20241120`
5. **CONFIRMATION REQUIRED**: Created branch correctly?
### Step 5: Cherry-pick Changes
For each commit:
1. Attempt cherry-pick: `git cherry-pick <commit>`
2. If conflicts occur:
- Display conflict details
- Show conflicting sections
- Provide resolution guidance
- **CONFIRMATION REQUIRED**: Conflicts resolved correctly?
3. After successful cherry-pick:
- Show the changes: `git show HEAD`
- Run validation: `npm run typecheck && npm run lint`
4. **CONFIRMATION REQUIRED**: Cherry-pick successful and valid?
### Step 6: Create PR to Core Branch
1. Push the hotfix branch: `git push origin hotfix/<version>-<timestamp>`
2. Create PR using gh CLI:
```bash
gh pr create --base core/X.Y --head hotfix/<version>-<timestamp> \
--title "[Hotfix] Cherry-pick fixes to core/X.Y" \
--body "Cherry-picked commits: ..."
```
3. Add appropriate labels (but NOT "Release" yet)
4. PR body should include:
- List of cherry-picked commits/PRs
- Original issue references
- Testing instructions
- Impact assessment
5. **CONFIRMATION REQUIRED**: PR created correctly?
### Step 7: Wait for Tests
1. Monitor PR checks: `gh pr checks`
2. Display test results as they complete
3. If any tests fail:
- Show failure details
- Analyze if related to cherry-picks
- **DECISION REQUIRED**: Fix and continue, or abort?
4. Wait for all required checks to pass
5. **CONFIRMATION REQUIRED**: All tests passing?
### Step 8: Merge Hotfix PR
1. Verify all checks have passed
2. Check for required approvals
3. Merge the PR: `gh pr merge --merge`
4. Delete the hotfix branch
5. **CONFIRMATION REQUIRED**: PR merged successfully?
### Step 9: Create Version Bump
1. Checkout the core branch: `git checkout core/X.Y`
2. Pull latest changes: `git pull origin core/X.Y`
3. Read current version from package.json
4. Determine patch version increment:
- Current: `1.23.4` → New: `1.23.5`
5. Create release branch named with new version: `release/1.23.5`
6. Update version in package.json to `1.23.5`
7. Commit: `git commit -m "[release] Bump version to 1.23.5"`
8. **CONFIRMATION REQUIRED**: Version bump correct?
### Step 10: Create Release PR
1. Push release branch: `git push origin release/1.23.5`
2. Create PR with Release label:
```bash
gh pr create --base core/X.Y --head release/1.23.5 \
--title "[Release] v1.23.5" \
--body "..." \
--label "Release"
```
3. **CRITICAL**: Verify "Release" label is added
4. PR description should include:
- Version: `1.23.4` → `1.23.5`
- Included fixes (link to previous PR)
- Release notes for users
5. **CONFIRMATION REQUIRED**: Release PR has "Release" label?
### Step 11: Monitor Release Process
1. Wait for PR checks to pass
2. **FINAL CONFIRMATION**: Ready to trigger release by merging?
3. Merge the PR: `gh pr merge --merge`
4. Monitor release workflow:
```bash
gh run list --workflow=release.yaml --limit=1
gh run watch
```
5. Track progress:
- GitHub release draft/publication
- PyPI upload
- npm types publication
### Step 12: Post-Release Verification
1. Verify GitHub release:
```bash
gh release view v1.23.5
```
2. Check PyPI package:
```bash
pip index versions comfyui-frontend-package | grep 1.23.5
```
3. Verify npm package:
```bash
npm view @comfyorg/comfyui-frontend-types@1.23.5
```
4. Generate release summary with:
- Version released
- Commits included
- Issues fixed
- Distribution status
5. **CONFIRMATION REQUIRED**: Release completed successfully?
## Safety Checks
Throughout the process:
- Always verify core branch matches ComfyUI's requirements.txt
- For PRs: Ensure using correct commits (merge vs individual)
- Check version numbers follow semantic versioning
- **Critical**: "Release" label must be on version bump PR
- Validate cherry-picks don't break core branch stability
- Keep audit trail of all operations
## Rollback Procedures
If something goes wrong:
- Before push: `git reset --hard origin/core/X.Y`
- After PR creation: Close PR and start over
- After failed release: Create new patch version with fixes
- Document any issues for future reference
## Important Notes
- Core branch version will be behind main - this is expected
- The "Release" label triggers the PyPI/npm publication
- PR numbers must include the `#` prefix
- Mixed commits/PRs are supported but review carefully
- Always wait for full test suite before proceeding
## Expected Timeline
- Step 1-3: ~10 minutes (analysis)
- Steps 4-6: ~15-30 minutes (cherry-picking)
- Step 7: ~10-20 minutes (tests)
- Steps 8-10: ~10 minutes (version bump)
- Step 11-12: ~15-20 minutes (release)
- Total: ~60-90 minutes
This process ensures a safe, verified hotfix release with multiple confirmation points and clear tracking of what changes are being released.

36
.github/CLAUDE.md vendored Normal file
View File

@@ -0,0 +1,36 @@
# ComfyUI Frontend - Claude Review Context
This file provides additional context for the automated PR review system.
## Quick Reference
### PrimeVue Component Migrations
When reviewing, flag these deprecated components:
- `Dropdown` → Use `Select` from 'primevue/select'
- `OverlayPanel` → Use `Popover` from 'primevue/popover'
- `Calendar` → Use `DatePicker` from 'primevue/datepicker'
- `InputSwitch` → Use `ToggleSwitch` from 'primevue/toggleswitch'
- `Sidebar` → Use `Drawer` from 'primevue/drawer'
- `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
- `TabMenu` → Use `Tabs` without panels
- `Steps` → Use `Stepper` without panels
- `InlineMessage` → Use `Message` component
### API Utilities Reference
- `api.apiURL()` - Backend API calls (/prompt, /queue, /view, etc.)
- `api.fileURL()` - Static file access (templates, extensions)
- `$t()` / `i18n.global.t()` - Internationalization
- `DOMPurify.sanitize()` - HTML sanitization
## Review Scope
This automated review performs comprehensive analysis including:
- Architecture and design patterns
- Security vulnerabilities
- Performance implications
- Code quality and maintainability
- Integration concerns
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.

75
.github/workflows/claude-pr-review.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Claude PR Review
permissions:
contents: read
pull-requests: write
issues: write
on:
pull_request:
types: [labeled]
jobs:
wait-for-ci:
runs-on: ubuntu-latest
if: github.event.label.name == 'claude-review'
outputs:
should-proceed: ${{ steps.check-status.outputs.proceed }}
steps:
- name: Wait for other CI checks
uses: lewagon/wait-on-check-action@v1.3.1
with:
ref: ${{ github.event.pull_request.head.sha }}
check-regexp: '^(ESLint|Prettier Check|Tests CI|Vitest Tests)'
wait-interval: 30
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if we should proceed
id: check-status
run: |
# Get all check runs for this commit
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("ESLint|Prettier Check|Tests CI|Vitest Tests")) | {name, conclusion}')
# Check if any required checks failed
if echo "$CHECK_RUNS" | grep -q '"conclusion": "failure"'; then
echo "Some CI checks failed - skipping Claude review"
echo "proceed=false" >> $GITHUB_OUTPUT
else
echo "All CI checks passed - proceeding with Claude review"
echo "proceed=true" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
claude-review:
needs: wait-for-ci
if: needs.wait-for-ci.outputs.should-proceed == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies for analysis tools
run: |
npm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@main
with:
prompt_file: .claude/commands/comprehensive-pr-review.md
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
max_turns: 1
timeout_minutes: 30
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
REPOSITORY: ${{ github.repository }}

27
.github/workflows/danger.yaml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Danger PR Review
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
danger:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm ci
- name: Run Danger
run: npx danger ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -3,6 +3,12 @@ name: Update Locales
on:
pull_request:
branches: [ main, master, dev* ]
paths-ignore:
- '.github/**'
- '.husky/**'
- '.vscode/**'
- 'browser_tests/**'
- 'tests-ui/**'
jobs:
update-locales:

View File

@@ -15,6 +15,7 @@ jobs:
contains(github.event.pull_request.labels.*.name, 'Release')
outputs:
version: ${{ steps.current_version.outputs.version }}
is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -24,6 +25,15 @@ jobs:
- name: Get current version
id: current_version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Check if prerelease
id: check_prerelease
run: |
VERSION=${{ steps.current_version.outputs.version }}
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Build project
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
@@ -62,9 +72,9 @@ jobs:
dist.zip
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: ${{ github.event.pull_request.base.ref == 'main' }}
draft: ${{ github.event.pull_request.base.ref != 'main' }}
prerelease: false
make_latest: ${{ github.event.pull_request.base.ref == 'main' && needs.build.outputs.is_prerelease == 'false' }}
draft: ${{ github.event.pull_request.base.ref != 'main' || needs.build.outputs.is_prerelease == 'true' }}
prerelease: ${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true
publish_pypi:

View File

@@ -88,7 +88,7 @@ jobs:
- name: Start ComfyUI server
run: |
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
python main.py --cpu --multi-user --cache-none --front-end-root ../ComfyUI_frontend/dist &
wait-for-it --service 127.0.0.1:8188 -t 600
working-directory: ComfyUI
@@ -97,7 +97,7 @@ jobs:
working-directory: ComfyUI_frontend
- name: Run Playwright tests (${{ matrix.browser }})
run: npx playwright test --project=${{ matrix.browser }}
run: npx playwright test --project=${{ matrix.browser }} --workers=1
working-directory: ComfyUI_frontend
- uses: actions/upload-artifact@v4

View File

@@ -8,10 +8,12 @@ on:
required: true
default: 'patch'
type: 'choice'
options:
- patch
- minor
- major
options: [patch, minor, major, prepatch, preminor, premajor, prerelease]
pre_release:
description: Pre-release ID (suffix)
required: false
default: ''
type: string
jobs:
bump-version:
@@ -33,19 +35,25 @@ jobs:
- name: Bump version
id: bump-version
run: |
npm version ${{ github.event.inputs.version_type }} --no-git-tag-version
npm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Format PR string
id: capitalised
run: |
CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Bump version to ${{ steps.bump-version.outputs.NEW_VERSION }}'
title: '${{ steps.bump-version.outputs.NEW_VERSION }}'
commit-message: '[release] Increment version to ${{ steps.bump-version.outputs.NEW_VERSION }}'
title: ${{ steps.bump-version.outputs.NEW_VERSION }}
body: |
Automated version bump to ${{ steps.bump-version.outputs.NEW_VERSION }}
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
base: main
labels: |
Release
Release

View File

@@ -1,5 +1,9 @@
if [[ "$OS" == "Windows_NT" ]]; then
npx.cmd lint-staged
# Check for unused i18n keys in staged files
npx.cmd tsx scripts/check-unused-i18n-keys.ts
else
npx lint-staged
# Check for unused i18n keys in staged files
npx tsx scripts/check-unused-i18n-keys.ts
fi

View File

@@ -9,9 +9,10 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
Note: For Traditional Chinese (Taiwan), use Taiwan-specific terminology and traditional characters.
`
});

View File

@@ -1,8 +1,9 @@
- use npm run to see what commands are available
- use `npm run` to see what commands are available
- For component communication, prefer Vue's event-based pattern (emit/@event-name) for state changes and notifications; use defineExpose with refs only for imperative operations that need direct control (like form.validate(), modal.open(), or editor.focus()); events promote loose coupling and are better for reusable components, while exposed methods are acceptable for tightly-coupled component pairs or when wrapping third-party libraries that require imperative APIs
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
- Never add lines to PR descriptions that say "Generated with Claude Code"
- Never add lines to PR descriptions or commit messages that say "Generated with Claude Code"
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
@@ -17,7 +18,6 @@
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
- Never write css if you can accomplish the same thing with tailwind utility classes
- Use setup() function for component logic
- Utilize ref and reactive for reactive state
- Implement computed properties with computed()
- Use watch and watchEffect for side effects
@@ -27,14 +27,12 @@
- Use Tailwind CSS for styling
- Leverage VueUse functions for performance-enhancing styles
- Use lodash for utility functions
- Use TypeScript for type safety
- Implement proper props and emits definitions
- Utilize Vue 3's Teleport component when needed
- Use Suspense for async components
- Implement proper error handling
- Follow Vue 3 style guide and naming conventions
- Use Vite for fast development and building
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
- IMPORTANT: Use vue-i18n for ALL user-facing strings - no hard-coded text in services/utilities. Place new translation entries in src/locales/en/main.json
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.
- DO NOT use deprecated PrimeVue components. Use these replacements instead:
* `Dropdown` → Use `Select` (import from 'primevue/select')
@@ -54,3 +52,7 @@
- Templates: `api.fileURL('/templates/default.json')`
- Extensions: `api.fileURL(extensionPath)` for loading JS modules
- Any static assets that exist in the public directory
- When implementing code that outputs raw HTML (e.g., using v-html directive), always ensure dynamic content has been properly sanitized with DOMPurify or validated through trusted sources. Prefer Vue templates over v-html when possible.
- For any async operations (API calls, timers, etc), implement cleanup/cancellation in component unmount to prevent memory leaks
- Extract complex template conditionals into separate components or computed properties
- Error messages should be actionable and user-friendly (e.g., "Failed to load data. Please refresh the page." instead of "Unknown error")

View File

@@ -529,7 +529,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
### Prerequisites & Technology Stack
- **Required Software**:
- Node.js (v16 or later) and npm
- Node.js (v16 or later; v20/v22 strongly recommended) and npm
- Git for version control
- A running ComfyUI backend instance

View File

@@ -14,7 +14,7 @@ Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` dir
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or later installed. Then, set up the Chromium test driver:
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
```bash
npx playwright install chromium --with-deps
```
@@ -26,9 +26,25 @@ The `.env` file will not exist until you create it yourself.
A template with helpful information can be found in `.env_example`.
### Running ComfyUI Backend
When running browser tests, ComfyUI must be started with the `--cache-none` argument to disable execution caching:
```bash
python main.py --cache-none
```
### Multiple Tests
If you are running Playwright tests in parallel or running the same test multiple times, the flag `--multi-user` must be added to the main ComfyUI process.
### Release API Mocking
By default, all tests mock the release API (`api.comfy.org/releases`) to prevent release notification popups from interfering with test execution. This is necessary because the release notifications can appear over UI elements and block test interactions.
To test with real release data, you can disable mocking:
```typescript
await comfyPage.setup({ mockReleases: false });
```
For tests that specifically need to test release functionality, see the example in `tests/releaseNotifications.spec.ts`.
## Running Tests
There are multiple ways to run the tests:

View File

@@ -0,0 +1,293 @@
{
"id": "test-subgraph-workflow",
"revision": 0,
"last_node_id": 12,
"last_link_id": 10,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [1], "slot_index": 0 },
{ "name": "MASK", "type": "MASK", "links": null, "slot_index": 1 }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 10,
"type": "test-subgraph-1",
"pos": [400, 200],
"size": [200, 80],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "IMAGE", "type": "IMAGE", "link": 1 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [9] }
],
"title": "Test Subgraph",
"properties": {},
"widgets_values": []
},
{
"id": 11,
"type": "SaveImage",
"pos": [700, 200],
"size": [315, 270],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 9 }
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [
[1, 1, 0, 10, 0, "IMAGE"],
[9, 10, 0, 11, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "test-subgraph-1",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 7,
"lastLinkId": 6,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [-154, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [800, 200, 120, 60]
},
"inputs": [
{
"id": "input-1",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [1],
"localized_name": "IMAGE",
"pos": {
"0": -134,
"1": 220
}
}
],
"outputs": [
{
"id": "output-1",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [5],
"localized_name": "IMAGE",
"pos": {
"0": 820,
"1": 220
}
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "TestSleep",
"pos": [100, 200],
"size": [210, 86],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 1 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [2], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestSleep" },
"widgets_values": [2.0]
},
{
"id": 5,
"type": "test-subgraph-2",
"pos": [350, 200],
"size": [200, 80],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "IMAGE", "type": "IMAGE", "link": 2 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [5] }
],
"title": "Nested Test Subgraph",
"properties": {},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 2,
"origin_id": 3,
"origin_slot": 0,
"target_id": 5,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 5,
"origin_id": 5,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
},
{
"id": "test-subgraph-2",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 7,
"lastLinkId": 4,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Nested Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [-154, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [600, 200, 120, 60]
},
"inputs": [
{
"id": "input-1",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [1],
"localized_name": "IMAGE",
"pos": {
"0": -134,
"1": 220
}
}
],
"outputs": [
{
"id": "output-1",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [4],
"localized_name": "IMAGE",
"pos": {
"0": 620,
"1": 220
}
}
],
"widgets": [],
"nodes": [
{
"id": 6,
"type": "TestSleep",
"pos": [100, 150],
"size": [210, 86],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 1 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [2], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestSleep" },
"widgets_values": [2.0]
},
{
"id": 7,
"type": "TestAsyncProgressNode",
"pos": [350, 150],
"size": [210, 126],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 2 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [4], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestAsyncProgressNode" },
"widgets_values": [3.0, 10]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 2,
"origin_id": 6,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 4,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -0,0 +1,139 @@
{
"last_node_id": 12,
"last_link_id": 10,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [26, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [1, 2, 3], "slot_index": 0 },
{ "name": "MASK", "type": "MASK", "links": null, "slot_index": 1 }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "TestSleep",
"pos": [400, 100],
"size": [210, 86],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 1 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [4], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestSleep" },
"widgets_values": [2.0]
},
{
"id": 3,
"type": "TestSleep",
"pos": [400, 250],
"size": [210, 86],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 2 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [5], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestSleep" },
"widgets_values": [2.5]
},
{
"id": 4,
"type": "TestAsyncProgressNode",
"pos": [400, 400],
"size": [210, 126],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{ "name": "value", "type": "IMAGE", "link": 3 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [6], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestAsyncProgressNode" },
"widgets_values": [3.0, 10]
},
{
"id": 5,
"type": "TestVariadicAverage",
"pos": [700, 200],
"size": [210, 106],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{ "name": "input1", "type": "IMAGE", "link": 4 },
{ "name": "input2", "type": "IMAGE", "link": 5 },
{ "name": "input3", "type": "IMAGE", "link": 6 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [7], "slot_index": 0 }
],
"properties": { "Node name for S&R": "TestVariadicAverage" }
},
{
"id": 6,
"type": "SaveImage",
"pos": [1000, 200],
"size": [315, 270],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 7 }
],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [700, 400],
"size": [210, 54],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null }
],
"outputs": [
{ "name": "CONDITIONING", "type": "CONDITIONING", "links": [], "slot_index": 0 }
],
"properties": { "Node name for S&R": "CLIPTextEncode" },
"widgets_values": ["test"]
}
],
"links": [
[1, 1, 0, 2, 0, "IMAGE"],
[2, 1, 0, 3, 0, "IMAGE"],
[3, 1, 0, 4, 0, "IMAGE"],
[4, 2, 0, 5, 0, "IMAGE"],
[5, 3, 0, 5, 1, "IMAGE"],
[6, 4, 0, 5, 2, "IMAGE"],
[7, 5, 0, 6, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,193 @@
{
"last_node_id": 12,
"last_link_id": 13,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [26, 474],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [1, 10], "slot_index": 0 },
{ "name": "CLIP", "type": "CLIP", "links": [2, 3], "slot_index": 1 },
{ "name": "VAE", "type": "VAE", "links": [8, 11], "slot_index": 2 }
],
"properties": {},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [415, 186],
"size": [422.84503173828125, 164.31304931640625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [{ "name": "clip", "type": "CLIP", "link": 2 }],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [4, 12],
"slot_index": 0
}
],
"properties": {},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [413, 389],
"size": [425.27801513671875, 180.6060791015625],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [5, 13],
"slot_index": 0
}
],
"properties": {},
"widgets_values": ["text, watermark"]
},
{
"id": 4,
"type": "EmptyLatentImage",
"pos": [473, 609],
"size": [315, 106],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [6, 14], "slot_index": 0 }],
"properties": {},
"widgets_values": [512, 512, 1]
},
{
"id": 5,
"type": "KSampler",
"pos": [863, 186],
"size": [315, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": 1 },
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
{ "name": "negative", "type": "CONDITIONING", "link": 5 },
{ "name": "latent_image", "type": "LATENT", "link": 6 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }],
"properties": {},
"widgets_values": [156680208700286, true, 2, 8, "euler", "normal", 1]
},
{
"id": 6,
"type": "VAEDecode",
"pos": [1209, 188],
"size": [210, 46],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{ "name": "samples", "type": "LATENT", "link": 7 },
{ "name": "vae", "type": "VAE", "link": 8 }
],
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0 }],
"properties": {}
},
{
"id": 7,
"type": "SaveImage",
"pos": [1451, 189],
"size": [210, 26],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 8,
"type": "KSampler",
"pos": [863, 486],
"size": [315, 262],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": 10 },
{ "name": "positive", "type": "CONDITIONING", "link": 12 },
{ "name": "negative", "type": "CONDITIONING", "link": 13 },
{ "name": "latent_image", "type": "LATENT", "link": 14 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [15], "slot_index": 0 }],
"properties": {},
"widgets_values": [156680208700287, true, 3, 8, "euler", "normal", 1]
},
{
"id": 9,
"type": "VAEDecode",
"pos": [1209, 488],
"size": [210, 46],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{ "name": "samples", "type": "LATENT", "link": 15 },
{ "name": "vae", "type": "VAE", "link": 11 }
],
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [16], "slot_index": 0 }],
"properties": {}
},
{
"id": 10,
"type": "SaveImage",
"pos": [1451, 489],
"size": [210, 26],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [{ "name": "images", "type": "IMAGE", "link": 16 }],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [
[1, 1, 0, 5, 0, "MODEL"],
[2, 1, 1, 2, 0, "CLIP"],
[3, 1, 1, 3, 0, "CLIP"],
[4, 2, 0, 5, 1, "CONDITIONING"],
[5, 3, 0, 5, 2, "CONDITIONING"],
[6, 4, 0, 5, 3, "LATENT"],
[7, 5, 0, 6, 0, "LATENT"],
[8, 1, 2, 6, 1, "VAE"],
[9, 6, 0, 7, 0, "IMAGE"],
[10, 1, 0, 8, 0, "MODEL"],
[11, 1, 2, 9, 1, "VAE"],
[12, 2, 0, 8, 1, "CONDITIONING"],
[13, 3, 0, 8, 2, "CONDITIONING"],
[14, 4, 0, 8, 3, "LATENT"],
[15, 8, 0, 9, 0, "LATENT"],
[16, 9, 0, 10, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,716 @@
{
"id": "976d6e9a-927d-42db-abd4-96bfc0ecf8d9",
"revision": 0,
"last_node_id": 10,
"last_link_id": 11,
"nodes": [
{
"id": 10,
"type": "8beb610f-ddd1-4489-ae0d-2f732a4042ae",
"pos": [
532,
412.5
],
"size": [
140,
46
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
10
]
},
{
"name": "VAE",
"type": "VAE",
"links": [
11
]
}
],
"title": "subgraph 2",
"properties": {},
"widgets_values": []
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
758.2109985351562,
398.3681335449219
],
"size": [
210,
46
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 10
},
{
"name": "vae",
"type": "VAE",
"link": 11
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1028.9615478515625,
381.83746337890625
],
"size": [
210,
270
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"ComfyUI"
]
}
],
"links": [
[
9,
8,
0,
9,
0,
"IMAGE"
],
[
10,
10,
0,
8,
0,
"LATENT"
],
[
11,
10,
1,
8,
1,
"VAE"
]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "8beb610f-ddd1-4489-ae0d-2f732a4042ae",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 14,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "subgraph 2",
"inputNode": {
"id": -10,
"bounding": [
-154,
415.5,
120,
40
]
},
"outputNode": {
"id": -20,
"bounding": [
1238,
395.5,
120,
80
]
},
"inputs": [],
"outputs": [
{
"id": "4d6c7e4e-971e-4f78-9218-9a604db53a4b",
"name": "LATENT",
"type": "LATENT",
"linkIds": [
7
],
"localized_name": "LATENT",
"pos": {
"0": 1258,
"1": 415.5
}
},
{
"id": "f8201d4f-7fc6-4a1b-b8c9-9f0716d9c09a",
"name": "VAE",
"type": "VAE",
"linkIds": [
14
],
"localized_name": "VAE",
"pos": {
"0": 1258,
"1": 435.5
}
}
],
"widgets": [],
"nodes": [
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
415,
186
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 13
}
],
"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,"
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
863,
186
],
"size": [
315,
262
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 10
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 11
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
32115495257102,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 10,
"type": "dbe5763f-440b-47b4-82ac-454f1f98b0e3",
"pos": [
194.13900756835938,
657.3333740234375
],
"size": [
140,
106
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
10
]
},
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
11
]
},
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"links": [
12
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
13
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
14
]
}
],
"title": "subgraph 3",
"properties": {},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": 6,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 10,
"origin_id": 10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 11,
"origin_id": 10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 12,
"origin_id": 10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": 10,
"origin_slot": 3,
"target_id": 6,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 14,
"origin_id": 10,
"origin_slot": 4,
"target_id": -20,
"target_slot": 1,
"type": "VAE"
}
],
"extra": {}
},
{
"id": "dbe5763f-440b-47b4-82ac-454f1f98b0e3",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 9,
"lastLinkId": 9,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "subgraph 3",
"inputNode": {
"id": -10,
"bounding": [
-154,
517,
120,
40
]
},
"outputNode": {
"id": -20,
"bounding": [
898.2780151367188,
467,
128.6640625,
140
]
},
"inputs": [],
"outputs": [
{
"id": "b4882169-329b-43f6-a373-81abfbdea55b",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [
6
],
"localized_name": "CONDITIONING",
"pos": {
"0": 918.2780151367188,
"1": 487
}
},
{
"id": "01f51f96-a741-428e-8772-9557ee50b609",
"name": "LATENT",
"type": "LATENT",
"linkIds": [
2
],
"localized_name": "LATENT",
"pos": {
"0": 918.2780151367188,
"1": 507
}
},
{
"id": "47fa906e-d80b-45c3-a596-211a0e59d4a1",
"name": "MODEL",
"type": "MODEL",
"linkIds": [
1
],
"localized_name": "MODEL",
"pos": {
"0": 918.2780151367188,
"1": 527
}
},
{
"id": "f03dccd7-10e8-4513-9994-15854a92d192",
"name": "CLIP",
"type": "CLIP",
"linkIds": [
3
],
"localized_name": "CLIP",
"pos": {
"0": 918.2780151367188,
"1": 547
}
},
{
"id": "a666877f-e34f-49bc-8a78-b26156656b83",
"name": "VAE",
"type": "VAE",
"linkIds": [
8
],
"localized_name": "VAE",
"pos": {
"0": 918.2780151367188,
"1": 567
}
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
413,
389
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 2,
"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": 5,
"type": "EmptyLatentImage",
"pos": [
473,
609
],
"size": [
315,
106
],
"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": 4,
"type": "CheckpointLoaderSimple",
"pos": [
26,
474
],
"size": [
315,
98
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
1
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
3,
5
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
8
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly-fp16.safetensors"
]
}
],
"groups": [],
"links": [
{
"id": 5,
"origin_id": 4,
"origin_slot": 1,
"target_id": 7,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 6,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 5,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "LATENT"
},
{
"id": 1,
"origin_id": 4,
"origin_slot": 0,
"target_id": -20,
"target_slot": 2,
"type": "MODEL"
},
{
"id": 3,
"origin_id": 4,
"origin_slot": 1,
"target_id": -20,
"target_slot": 3,
"type": "CLIP"
},
{
"id": 8,
"origin_id": 4,
"origin_slot": 2,
"target_id": -20,
"target_slot": 4,
"type": "VAE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.24.0-1"
},
"version": 0.4
}

View File

@@ -268,8 +268,34 @@ export class ComfyPage {
return this._history
}
async setup({ clearStorage = true }: { clearStorage?: boolean } = {}) {
async setup({
clearStorage = true,
mockReleases = true
}: {
clearStorage?: boolean
mockReleases?: boolean
} = {}) {
await this.goto()
// Mock release endpoint to prevent changelog popups
if (mockReleases) {
await this.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
} else {
await route.continue()
}
})
}
if (clearStorage) {
await this.page.evaluate((id) => {
localStorage.clear()
@@ -1086,7 +1112,7 @@ export const comfyPageFixture = base.extend<{
},
comfyMouse: async ({ comfyPage }, use) => {
const comfyMouse = new ComfyMouse(comfyPage)
use(comfyMouse)
await use(comfyMouse)
}
})

View File

@@ -0,0 +1,507 @@
import { Page } from '@playwright/test'
export interface ExecutionEventTracker {
progressStates: any[]
executionStarted: boolean
executionFinished: boolean
executionError: any | null
executingNodeId: string | null
}
export interface ProgressState {
prompt_id: string
nodes: Record<
string,
{
state: 'running' | 'finished' | 'waiting'
node_id: string
display_node_id: string
prompt_id: string
value?: number
max?: number
}
>
}
export class ExecutionTestHelper {
private testId: string
constructor(private page: Page) {
// Generate unique ID for this test instance to avoid conflicts
this.testId = `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
/**
* Sets up common event tracking for execution tests
*/
async setupEventTracking(): Promise<void> {
await this.page.evaluate((testId) => {
// Use unique property names for this test instance
const progressKey = `__progressStates_${testId}`
const startedKey = `__executionStarted_${testId}`
const finishedKey = `__executionFinished_${testId}`
const errorKey = `__executionError_${testId}`
const nodeIdKey = `__executingNodeId_${testId}`
window[progressKey] = []
window[startedKey] = false
window[finishedKey] = false
window[errorKey] = null
window[nodeIdKey] = null
const api = window['app'].api
// Store listeners so we can clean them up later
if (!window['__testListeners']) {
window['__testListeners'] = {}
}
// Remove old listeners if they exist
if (window['__testListeners'][testId]) {
const oldListeners = window['__testListeners'][testId]
api.removeEventListener('progress_state', oldListeners.progress)
api.removeEventListener('executing', oldListeners.executing)
api.removeEventListener('execution_error', oldListeners.error)
}
// Create new listeners
const progressListener = (event) => {
window[progressKey].push(event.detail)
}
const executingListener = (event) => {
window[nodeIdKey] = event.detail
if (event.detail !== null) {
window[startedKey] = true
} else {
window[finishedKey] = true
}
}
const errorListener = (event) => {
window[errorKey] = event.detail
}
// Add listeners
api.addEventListener('progress_state', progressListener)
api.addEventListener('executing', executingListener)
api.addEventListener('execution_error', errorListener)
// Store listeners for cleanup
window['__testListeners'][testId] = {
progress: progressListener,
executing: executingListener,
error: errorListener
}
}, this.testId)
}
/**
* Gets the current event tracking state
*/
async getEventState(): Promise<ExecutionEventTracker> {
return await this.page.evaluate(
(testId) => ({
progressStates: window[`__progressStates_${testId}`] || [],
executionStarted: window[`__executionStarted_${testId}`] || false,
executionFinished: window[`__executionFinished_${testId}`] || false,
executionError: window[`__executionError_${testId}`] || null,
executingNodeId: window[`__executingNodeId_${testId}`] || null
}),
this.testId
)
}
/**
* Waits for execution to start
*/
async waitForExecutionStart(timeout: number = 10000): Promise<void> {
await this.page.waitForFunction(
(testId) => window[`__executionStarted_${testId}`] === true,
this.testId,
{ timeout }
)
}
/**
* Waits for execution to finish
*/
async waitForExecutionFinish(timeout: number = 30000): Promise<void> {
await this.page.waitForFunction(
(testId) => window[`__executionFinished_${testId}`] === true,
this.testId,
{ timeout }
)
}
/**
* Waits for a specific number of nodes to be running
*/
async waitForRunningNodes(
count: number,
timeout: number = 10000
): Promise<void> {
await this.page.waitForFunction(
({ expectedCount, testId }) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
const runningNodes = Object.values(latestState.nodes).filter(
(node: any) => node.state === 'running'
).length
return runningNodes >= expectedCount
},
{ expectedCount: count, testId: this.testId },
{ timeout }
)
}
/**
* Waits for at least one node to finish
*/
async waitForNodeFinish(timeout: number = 15000): Promise<void> {
await this.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
return Object.values(latestState.nodes).some(
(node: any) => node.state === 'finished'
)
},
this.testId,
{ timeout }
)
}
/**
* Gets the latest progress state
*/
async getLatestProgressState(): Promise<ProgressState | null> {
return await this.page.evaluate((testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return null
return states[states.length - 1]
}, this.testId)
}
/**
* Waits for node progress to be applied to the graph
*/
async waitForGraphNodeProgress(
nodeIds: number[],
timeout: number = 5000
): Promise<void> {
await this.page.waitForFunction(
(ids) => {
return ids.some((id) => {
const node = window['app'].graph.getNodeById(id)
return node?.progress !== undefined && node.progress >= 0
})
},
nodeIds,
{ timeout }
)
}
/**
* Gets node progress from the graph
*/
async getGraphNodeProgress(nodeId: number): Promise<number | undefined> {
return await this.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
return node?.progress
}, nodeId)
}
/**
* Checks if execution had errors
*/
async hasExecutionError(): Promise<boolean> {
const error = await this.page.evaluate(
(testId) => window[`__executionError_${testId}`],
this.testId
)
return error !== null
}
/**
* Gets execution error details
*/
async getExecutionError(): Promise<any> {
return await this.page.evaluate(
(testId) => window[`__executionError_${testId}`],
this.testId
)
}
/**
* Cleanup event listeners when test is done
*/
async cleanup(): Promise<void> {
await this.page.evaluate((testId) => {
if (window['__testListeners'] && window['__testListeners'][testId]) {
const api = window['app'].api
const listeners = window['__testListeners'][testId]
api.removeEventListener('progress_state', listeners.progress)
api.removeEventListener('executing', listeners.executing)
api.removeEventListener('execution_error', listeners.error)
delete window['__testListeners'][testId]
}
// Clean up test-specific properties
delete window[`__progressStates_${testId}`]
delete window[`__executionStarted_${testId}`]
delete window[`__executionFinished_${testId}`]
delete window[`__executionError_${testId}`]
delete window[`__executingNodeId_${testId}`]
}, this.testId)
}
/**
* Get the testId for direct window access in evaluate functions
*/
getTestId(): string {
return this.testId
}
}
/**
* Helper for browser title monitoring
*/
export class BrowserTitleMonitor {
constructor(private page: Page) {}
/**
* Waits for title to not show execution state
*/
async waitForIdleTitle(timeout: number = 10000): Promise<void> {
await this.page.waitForFunction(
() => {
const title = document.title
return !title.match(/\[\d+%\]/) && !title.match(/\[\d+ nodes running\]/)
},
{ timeout }
)
}
/**
* Waits for title to show execution state
*/
async waitForExecutionTitle(timeout: number = 5000): Promise<void> {
await this.page.waitForFunction(
() => {
const title = document.title
return title.match(/\[\d+%\]/) || title.match(/\[\d+ nodes running\]/)
},
{ timeout }
)
}
/**
* Sets up title change monitoring
*/
async setupTitleMonitoring(): Promise<void> {
await this.page.evaluate(() => {
window['__titleUpdateLog'] = []
window['__lastTitle'] = document.title
window['__titleInterval'] = setInterval(() => {
const newTitle = document.title
if (newTitle !== window['__lastTitle']) {
window['__titleUpdateLog'].push({
time: Date.now(),
title: newTitle,
hasProgress: !!newTitle.match(/\[\d+%\]/),
hasMultiNode: !!newTitle.match(/\[\d+ nodes running\]/)
})
window['__lastTitle'] = newTitle
}
}, 50)
})
}
/**
* Stops title monitoring and returns the log
*/
async stopTitleMonitoring(): Promise<any[]> {
const log = await this.page.evaluate(() => {
if (window['__titleInterval']) {
clearInterval(window['__titleInterval'])
}
return window['__titleUpdateLog'] || []
})
return log
}
}
/**
* Helper for preview event handling
*/
export class PreviewTestHelper {
constructor(private page: Page) {}
/**
* Sets up preview event tracking
*/
async setupPreviewTracking(): Promise<void> {
await this.page.evaluate(() => {
window['__previewEvents'] = []
window['__revokedNodes'] = []
window['__revokedUrls'] = []
// Track preview events
const api = window['app'].api
api.addEventListener('b_preview_with_metadata', (event) => {
window['__previewEvents'].push({
nodeId: event.detail.nodeId,
displayNodeId: event.detail.displayNodeId,
parentNodeId: event.detail.parentNodeId,
realNodeId: event.detail.realNodeId,
promptId: event.detail.promptId
})
})
// Mock revokePreviews to track calls
const originalRevoke = window['app'].revokePreviews
window['app'].revokePreviews = function (nodeId) {
window['__revokedNodes'].push(nodeId)
originalRevoke.call(this, nodeId)
}
// Mock URL.revokeObjectURL to track URL revocations
const originalRevokeURL = URL.revokeObjectURL
URL.revokeObjectURL = (url: string) => {
window['__revokedUrls'].push(url)
originalRevokeURL.call(URL, url)
}
})
}
/**
* Gets tracked preview events
*/
async getPreviewEvents(): Promise<any[]> {
return await this.page.evaluate(() => window['__previewEvents'] || [])
}
/**
* Gets revoked node IDs
*/
async getRevokedNodes(): Promise<string[]> {
return await this.page.evaluate(() => window['__revokedNodes'] || [])
}
/**
* Gets revoked URLs
*/
async getRevokedUrls(): Promise<string[]> {
return await this.page.evaluate(() => window['__revokedUrls'] || [])
}
/**
* Sets fake preview for a node
*/
async setNodePreview(nodeId: string, previewUrl: string): Promise<void> {
await this.page.evaluate(
({ id, url }) => {
window['app'].nodePreviewImages[id] = [url]
},
{ id: nodeId, url: previewUrl }
)
}
/**
* Gets node preview URLs
*/
async getNodePreviews(nodeId: string): Promise<string[] | undefined> {
return await this.page.evaluate(
(id) => window['app'].nodePreviewImages[id],
nodeId
)
}
}
/**
* Helper for checking subgraph execution
*/
export class SubgraphTestHelper {
private testId: string
constructor(private page: Page) {
// Generate unique ID for this test instance
this.testId = `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
/**
* Sets the test ID to match ExecutionTestHelper
*/
setTestId(testId: string): void {
this.testId = testId
}
/**
* Waits for nested node progress (nodes with ':' in their ID)
*/
async waitForNestedNodeProgress(
minNestingLevel: number = 1,
timeout: number = 15000
): Promise<void> {
await this.page.waitForFunction(
({ minLevel, testId }) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
return states.some((state: any) => {
if (!state.nodes) return false
return Object.keys(state.nodes).some((nodeId) => {
const colonCount = (nodeId.match(/:/g) || []).length
return colonCount >= minLevel
})
})
},
{ minLevel: minNestingLevel, testId: this.testId },
{ timeout }
)
}
/**
* Gets all nested node IDs from progress states
*/
async getNestedNodeIds(): Promise<string[]> {
return await this.page.evaluate((testId) => {
const states = window[`__progressStates_${testId}`] || []
const nestedIds = new Set<string>()
states.forEach((state: any) => {
if (state.nodes) {
Object.keys(state.nodes).forEach((nodeId) => {
if (nodeId.includes(':')) {
nestedIds.add(nodeId)
}
})
}
})
return Array.from(nestedIds)
}, this.testId)
}
/**
* Checks if a node has running stroke style
*/
async hasRunningStrokeStyle(nodeId: number): Promise<boolean> {
return await this.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
if (!node?.strokeStyles?.['running']) return false
const style = node.strokeStyles['running'].call(node)
return style?.color === '#0f0'
}, nodeId)
}
}

View File

@@ -0,0 +1,316 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import {
BrowserTitleMonitor,
ExecutionTestHelper
} from '../helpers/ExecutionTestHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Browser Tab Title - Multi-node Execution', () => {
test.describe.configure({ mode: 'serial' })
let executionHelper: ExecutionTestHelper
let titleMonitor: BrowserTitleMonitor
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
executionHelper = new ExecutionTestHelper(comfyPage.page)
titleMonitor = new BrowserTitleMonitor(comfyPage.page)
})
test.afterEach(async () => {
// Clean up event listeners to avoid conflicts
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Shows multiple nodes running in tab title', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Wait for any existing execution to complete
await titleMonitor.waitForIdleTitle()
// Get initial title
const initialTitle = await comfyPage.page.title()
// Title might show execution state if other tests are running
// Just ensure we have a baseline to compare against
// Wait for the UI to be ready
await comfyPage.nextFrame()
// Check if workflow is valid and nodes are available
const workflowStatus = await comfyPage.page.evaluate(() => {
const graph = window['app'].graph
const missingNodeTypes: string[] = []
const nodeCount = graph.nodes.length
// Check for missing node types
graph.nodes.forEach((node: any) => {
if (node.type && !LiteGraph.registered_node_types[node.type]) {
missingNodeTypes.push(node.type)
}
})
return {
nodeCount,
missingNodeTypes,
hasErrors: missingNodeTypes.length > 0
}
})
if (workflowStatus.hasErrors) {
console.log('Missing node types:', workflowStatus.missingNodeTypes)
// Skip test if nodes are missing
test.skip()
return
}
// Set up tracking for progress events and errors
await executionHelper.setupEventTracking()
// Queue the workflow for real execution using the command
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait a moment to see if there's an error
await comfyPage.page.waitForTimeout(1000)
// Check for execution errors
if (await executionHelper.hasExecutionError()) {
const error = await executionHelper.getExecutionError()
console.log('Execution error:', error)
}
// Wait for multiple nodes to be running (TestSleep nodes 2, 3 and TestAsyncProgressNode 4)
await executionHelper.waitForRunningNodes(2)
// Check title while we know multiple nodes are running
const testId = executionHelper.getTestId()
const titleDuringExecution = await comfyPage.page.evaluate((testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return null
const latestState = states[states.length - 1]
if (!latestState.nodes) return null
const runningNodes = Object.values(latestState.nodes).filter(
(node: any) => node.state === 'running'
).length
return {
title: document.title,
runningCount: runningNodes
}
}, testId)
// Verify we captured the state with multiple nodes running
expect(titleDuringExecution).not.toBeNull()
expect(titleDuringExecution.runningCount).toBeGreaterThanOrEqual(2)
// The title should show multiple nodes running when we have 2+ nodes executing
if (titleDuringExecution.runningCount >= 2) {
expect(titleDuringExecution.title).toMatch(/\[\d+ nodes running\]/)
}
// Wait for some nodes to finish, leaving only one running
await executionHelper.waitForRunningNodes(1, 15000)
// Wait for title to show single node progress
await comfyPage.page.waitForFunction(
() => {
const title = document.title
return title.match(/\[\d+%\]/) && !title.match(/\[\d+ nodes running\]/)
},
{ timeout: 5000 }
)
// Check that title shows single node with progress
const titleWithSingleNode = await comfyPage.page.title()
expect(titleWithSingleNode).toMatch(/\[\d+%\]/)
expect(titleWithSingleNode).not.toMatch(/\[\d+ nodes running\]/)
})
test('Shows progress updates in title during execution', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Wait for the UI to be ready
await comfyPage.nextFrame()
// Set up tracking for progress events and title changes
await executionHelper.setupEventTracking()
await titleMonitor.setupTitleMonitoring()
// Queue the workflow for real execution using the command
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for TestAsyncProgressNode (node 4) to start showing progress
// This node reports progress from 0 to 10 with steps of 1
const testId2 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes || !latestState.nodes['4']) return false
const node4 = latestState.nodes['4']
if (node4.state === 'running' && node4.value > 0) {
window['__lastProgress'] = Math.round((node4.value / node4.max) * 100)
return true
}
return false
},
testId2,
{ timeout: 10000 }
)
// Wait for title to show progress percentage
await comfyPage.page.waitForFunction(
() => {
const title = document.title
console.log('Title check 1:', title)
return title.match(/\[\d+%\]/)
},
{ timeout: 5000 }
)
// Check that title shows a progress percentage
const titleWithProgress = await comfyPage.page.title()
expect(titleWithProgress).toMatch(/\[\d+%\]/)
// Wait for progress to update to a different value
const firstProgress = await comfyPage.page.evaluate(
() => window['__lastProgress']
)
const testId3 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
({ initialProgress, testId }) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes || !latestState.nodes['4']) return false
const node4 = latestState.nodes['4']
if (node4.state === 'running') {
const currentProgress = Math.round((node4.value / node4.max) * 100)
window['__lastProgress'] = currentProgress
return currentProgress > initialProgress
}
return false
},
{ initialProgress: firstProgress, testId: testId3 },
{ timeout: 10000 }
)
// Store the first progress for comparison
await comfyPage.page.evaluate((progress) => {
window['__firstProgress'] = progress
}, firstProgress)
// Check the title history to verify we captured progress updates
const finalCheck = await comfyPage.page.evaluate(() => {
const titleLog = window['__titleUpdateLog'] || []
const firstProgress = window['__firstProgress'] || 0
// Find titles with progress information
const titlesWithProgress = titleLog.filter((entry) => entry.hasProgress)
// Check if we saw different progress values or multi-node running state
const progressValues = new Set()
const hadMultiNodeRunning = titleLog.some((entry) =>
entry.title.includes('nodes running')
)
titleLog.forEach((entry) => {
const match = entry.title.match(/\[(\d+)%\]/)
if (match) {
progressValues.add(parseInt(match[1]))
}
})
return {
sawProgressUpdates: titlesWithProgress.length > 0,
uniqueProgressValues: Array.from(progressValues),
hadMultiNodeRunning,
firstProgress,
lastProgress: window['__lastProgress'],
totalTitleUpdates: titleLog.length,
sampleTitles: titleLog.slice(0, 5)
}
})
console.log('Title update check:', JSON.stringify(finalCheck, null, 2))
// Verify that we captured title updates showing execution progress
expect(finalCheck.sawProgressUpdates).toBe(true)
expect(finalCheck.totalTitleUpdates).toBeGreaterThan(0)
// We should have seen either:
// 1. Multiple unique progress values, OR
// 2. Multi-node running state, OR
// 3. Progress different from initial
const sawProgressChange =
finalCheck.uniqueProgressValues.length > 1 ||
finalCheck.hadMultiNodeRunning ||
finalCheck.lastProgress !== firstProgress
expect(sawProgressChange).toBe(true)
// Clean up interval
await titleMonitor.stopTitleMonitoring()
})
test('Clears execution status from title when all nodes finish', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Wait for any existing execution to complete
await titleMonitor.waitForIdleTitle()
// Wait for the UI to be ready
await comfyPage.nextFrame()
// Set up tracking for events
await executionHelper.setupEventTracking()
// Queue the workflow for real execution using the command
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to show progress in title
await titleMonitor.waitForExecutionTitle()
// Verify execution shows in title
const executingTitle = await comfyPage.page.title()
expect(executingTitle).toMatch(/\[[\d%\s\w]+\]/)
// Wait for execution to complete (all nodes finished)
await executionHelper.waitForExecutionFinish()
// Give a moment for title to update after execution completes
await comfyPage.page.waitForTimeout(500)
// Wait for title to clear execution status
await titleMonitor.waitForIdleTitle()
// Check that execution status is cleared
const finishedTitle = await comfyPage.page.title()
expect(finishedTitle).toContain('ComfyUI')
expect(finishedTitle).not.toMatch(/\[\d+%\]/) // No percentage
expect(finishedTitle).not.toMatch(/\[\d+ nodes running\]/) // No running nodes
expect(finishedTitle).not.toContain('Executing')
})
})

View File

@@ -0,0 +1,85 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import {
BrowserTitleMonitor,
ExecutionTestHelper
} from '../helpers/ExecutionTestHelper'
test.describe('Browser Tab Title - Multi-node Simple', () => {
test.describe.configure({ mode: 'serial' })
let executionHelper: ExecutionTestHelper
let titleMonitor: BrowserTitleMonitor
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
executionHelper = new ExecutionTestHelper(comfyPage.page)
titleMonitor = new BrowserTitleMonitor(comfyPage.page)
})
test.afterEach(async () => {
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Title updates based on execution state', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Wait for any existing execution to complete
await titleMonitor.waitForIdleTitle().catch(() => {
// If timeout, cancel any running execution
return comfyPage.page.keyboard.press('Escape')
})
// Get initial title
const initialTitle = await comfyPage.page.title()
// Title might show execution state if other tests are running
// Just ensure we can detect when it changes
const hasExecutionState =
initialTitle.match(/\[\d+%\]/) ||
initialTitle.match(/\[\d+ nodes running\]/)
// Set up tracking for execution events
await executionHelper.setupEventTracking()
// Start real execution using command instead of button
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Wait for title to update with execution state
await titleMonitor.waitForExecutionTitle()
const executingTitle = await comfyPage.page.title()
// If initial title didn't have execution state, it should be different now
if (!hasExecutionState) {
expect(executingTitle).not.toBe(initialTitle)
}
expect(executingTitle).toMatch(/\[[\d%\s\w]+\]/)
})
test('Can read workflow name from title', async ({ comfyPage }) => {
// Wait for any existing execution to complete
await titleMonitor.waitForIdleTitle(5000).catch(async () => {
// Cancel any running execution
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.waitForTimeout(1000)
})
// Set a workflow name
await comfyPage.page.evaluate(() => {
window['app'].extensionManager.workflow.activeWorkflow.filename =
'test-workflow'
})
// Wait for title to update
await comfyPage.page.waitForTimeout(100)
const title = await comfyPage.page.title()
expect(title).toContain('test-workflow')
// Title should contain workflow name regardless of execution state
})
})

View File

@@ -0,0 +1,382 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Feature Flags', () => {
test('Client and server exchange feature flags on connection', async ({
comfyPage
}) => {
// Navigate to a new page to capture the initial WebSocket connection
const newPage = await comfyPage.page.context().newPage()
// Set up monitoring before navigation
await newPage.addInitScript(() => {
// This runs before any page scripts
window.__capturedMessages = {
clientFeatureFlags: null,
serverFeatureFlags: null
}
// Capture outgoing client messages
const originalSend = WebSocket.prototype.send
WebSocket.prototype.send = function (data) {
try {
const parsed = JSON.parse(data)
if (parsed.type === 'feature_flags') {
window.__capturedMessages.clientFeatureFlags = parsed
}
} catch (e) {
// Not JSON, ignore
}
return originalSend.call(this, data)
}
// Monitor for server feature flags
const checkInterval = setInterval(() => {
if (
window['app']?.api?.serverFeatureFlags &&
Object.keys(window['app'].api.serverFeatureFlags).length > 0
) {
window.__capturedMessages.serverFeatureFlags =
window['app'].api.serverFeatureFlags
clearInterval(checkInterval)
}
}, 100)
// Clear after 10 seconds
setTimeout(() => clearInterval(checkInterval), 10000)
})
// Navigate to the app
await newPage.goto(comfyPage.url)
// Wait for both client and server feature flags
await newPage.waitForFunction(
() =>
window.__capturedMessages.clientFeatureFlags !== null &&
window.__capturedMessages.serverFeatureFlags !== null,
{ timeout: 10000 }
)
// Get the captured messages
const messages = await newPage.evaluate(() => window.__capturedMessages)
// Verify client sent feature flags
expect(messages.clientFeatureFlags).toBeTruthy()
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
expect(messages.clientFeatureFlags).toHaveProperty('data')
expect(messages.clientFeatureFlags.data).toHaveProperty(
'supports_preview_metadata'
)
expect(
typeof messages.clientFeatureFlags.data.supports_preview_metadata
).toBe('boolean')
// Verify server sent feature flags back
expect(messages.serverFeatureFlags).toBeTruthy()
expect(messages.serverFeatureFlags).toHaveProperty(
'supports_preview_metadata'
)
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
'boolean'
)
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
await newPage.close()
})
test('Server feature flags are received and accessible', async ({
comfyPage
}) => {
// Wait for connection to establish
await comfyPage.page.waitForTimeout(1000)
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window['app'].api.serverFeatureFlags
})
// Verify we received real feature flags from the backend
expect(serverFlags).toBeTruthy()
expect(Object.keys(serverFlags).length).toBeGreaterThan(0)
// The backend should send feature flags
expect(serverFlags).toHaveProperty('supports_preview_metadata')
expect(typeof serverFlags.supports_preview_metadata).toBe('boolean')
expect(serverFlags).toHaveProperty('max_upload_size')
expect(typeof serverFlags.max_upload_size).toBe('number')
})
test('serverSupportsFeature method works with real backend flags', async ({
comfyPage
}) => {
// Wait for connection
await comfyPage.page.waitForTimeout(1000)
// Test serverSupportsFeature with real backend flags
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
return window['app'].api.serverSupportsFeature(
'supports_preview_metadata'
)
})
// The method should return a boolean based on the backend's value
expect(typeof supportsPreviewMetadata).toBe('boolean')
// Test non-existent feature - should always return false
const supportsNonExistent = await comfyPage.page.evaluate(() => {
return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
})
expect(supportsNonExistent).toBe(false)
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window['app'].api.serverFeatureFlags
window['app'].api.serverFeatureFlags = {
bool_true: true,
bool_false: false,
string_value: 'yes',
number_value: 1,
null_value: null
}
const results = {
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
string_value: window['app'].api.serverSupportsFeature('string_value'),
number_value: window['app'].api.serverSupportsFeature('number_value'),
null_value: window['app'].api.serverSupportsFeature('null_value')
}
// Restore original
window['app'].api.serverFeatureFlags = original
return results
})
// serverSupportsFeature should only return true for boolean true values
expect(testResults.bool_true).toBe(true)
expect(testResults.bool_false).toBe(false)
expect(testResults.string_value).toBe(false)
expect(testResults.number_value).toBe(false)
expect(testResults.null_value).toBe(false)
})
test('getServerFeature method works with real backend data', async ({
comfyPage
}) => {
// Wait for connection
await comfyPage.page.waitForTimeout(1000)
// Test getServerFeature method
const previewMetadataValue = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeature('supports_preview_metadata')
})
expect(typeof previewMetadataValue).toBe('boolean')
// Test getting max_upload_size
const maxUploadSize = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeature('max_upload_size')
})
expect(typeof maxUploadSize).toBe('number')
expect(maxUploadSize).toBeGreaterThan(0)
// Test getServerFeature with default value for non-existent feature
const defaultValue = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeature(
'non_existent_feature_xyz',
'default'
)
})
expect(defaultValue).toBe('default')
})
test('getServerFeatures returns all backend feature flags', async ({
comfyPage
}) => {
// Wait for connection
await comfyPage.page.waitForTimeout(1000)
// Test getServerFeatures returns all flags
const allFeatures = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeatures()
})
expect(allFeatures).toBeTruthy()
expect(allFeatures).toHaveProperty('supports_preview_metadata')
expect(typeof allFeatures.supports_preview_metadata).toBe('boolean')
expect(allFeatures).toHaveProperty('max_upload_size')
expect(Object.keys(allFeatures).length).toBeGreaterThan(0)
})
test('Client feature flags are immutable', async ({ comfyPage }) => {
// Test that getClientFeatureFlags returns a copy
const immutabilityTest = await comfyPage.page.evaluate(() => {
const flags1 = window['app'].api.getClientFeatureFlags()
const flags2 = window['app'].api.getClientFeatureFlags()
// Modify the first object
flags1.test_modification = true
// Get flags again to check if original was modified
const flags3 = window['app'].api.getClientFeatureFlags()
return {
areEqual: flags1 === flags2,
hasModification: flags3.test_modification !== undefined,
hasSupportsPreview: flags1.supports_preview_metadata !== undefined,
supportsPreviewValue: flags1.supports_preview_metadata
}
})
// Verify they are different objects (not the same reference)
expect(immutabilityTest.areEqual).toBe(false)
// Verify modification didn't affect the original
expect(immutabilityTest.hasModification).toBe(false)
// Verify the flags contain expected properties
expect(immutabilityTest.hasSupportsPreview).toBe(true)
expect(typeof immutabilityTest.supportsPreviewValue).toBe('boolean') // From clientFeatureFlags.json
})
test('Server features are immutable when accessed via getServerFeatures', async ({
comfyPage
}) => {
// Wait for connection to establish
await comfyPage.page.waitForTimeout(1000)
const immutabilityTest = await comfyPage.page.evaluate(() => {
// Get a copy of server features
const features1 = window['app'].api.getServerFeatures()
// Try to modify it
features1.supports_preview_metadata = false
features1.new_feature = 'added'
// Get another copy
const features2 = window['app'].api.getServerFeatures()
return {
modifiedValue: features1.supports_preview_metadata,
originalValue: features2.supports_preview_metadata,
hasNewFeature: features2.new_feature !== undefined,
hasSupportsPreview: features2.supports_preview_metadata !== undefined
}
})
// The modification should only affect the copy
expect(immutabilityTest.modifiedValue).toBe(false)
expect(typeof immutabilityTest.originalValue).toBe('boolean') // Backend sends boolean for supports_preview_metadata
expect(immutabilityTest.hasNewFeature).toBe(false)
expect(immutabilityTest.hasSupportsPreview).toBe(true)
})
test('Feature flags are negotiated early in connection lifecycle', async ({
comfyPage
}) => {
// This test verifies that feature flags are available early in the app lifecycle
// which is important for protocol negotiation
// Create a new page to ensure clean state
const newPage = await comfyPage.page.context().newPage()
// Set up monitoring before navigation
await newPage.addInitScript(() => {
// Track when various app components are ready
;(window as any).__appReadiness = {
featureFlagsReceived: false,
apiInitialized: false,
appInitialized: false
}
// Monitor when feature flags arrive by checking periodically
const checkFeatureFlags = setInterval(() => {
if (
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
) {
;(window as any).__appReadiness.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
}
}, 10)
// Monitor API initialization
const checkApi = setInterval(() => {
if (window['app']?.api) {
;(window as any).__appReadiness.apiInitialized = true
clearInterval(checkApi)
}
}, 10)
// Monitor app initialization
const checkApp = setInterval(() => {
if (window['app']?.graph) {
;(window as any).__appReadiness.appInitialized = true
clearInterval(checkApp)
}
}, 10)
// Clean up after 10 seconds
setTimeout(() => {
clearInterval(checkFeatureFlags)
clearInterval(checkApi)
clearInterval(checkApp)
}, 10000)
})
// Navigate to the app
await newPage.goto(comfyPage.url)
// Wait for feature flags to be received
await newPage.waitForFunction(
() =>
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined,
{
timeout: 10000
}
)
// Get readiness state
const readiness = await newPage.evaluate(() => {
return {
...(window as any).__appReadiness,
currentFlags: window['app'].api.serverFeatureFlags
}
})
// Verify feature flags are available
expect(readiness.currentFlags).toHaveProperty('supports_preview_metadata')
expect(typeof readiness.currentFlags.supports_preview_metadata).toBe(
'boolean'
)
expect(readiness.currentFlags).toHaveProperty('max_upload_size')
// Verify feature flags were received (we detected them via polling)
expect(readiness.featureFlagsReceived).toBe(true)
// Verify API was initialized (feature flags require API)
expect(readiness.apiInitialized).toBe(true)
await newPage.close()
})
test('Backend /features endpoint returns feature flags', async ({
comfyPage
}) => {
// Test the HTTP endpoint directly
const response = await comfyPage.page.request.get(
`${comfyPage.url}/api/features`
)
expect(response.ok()).toBe(true)
const features = await response.json()
expect(features).toBeTruthy()
expect(features).toHaveProperty('supports_preview_metadata')
expect(typeof features.supports_preview_metadata).toBe('boolean')
expect(features).toHaveProperty('max_upload_size')
expect(Object.keys(features).length).toBeGreaterThan(0)
})
})

View File

@@ -17,11 +17,11 @@ test.describe('Group Node', () => {
await libraryTab.open()
})
test('Is added to node library sidebar', async ({ comfyPage }) => {
test.skip('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
test('Can be added to canvas using node library sidebar', async ({
test.skip('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
test.skip('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
).toHaveLength(0)
})
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
test.skip('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -95,7 +95,7 @@ test.describe('Group Node', () => {
)
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
test.skip('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test('Manage group opens with the correct group selected', async ({
test.skip('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name, type1, type2) => {
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
expect(visibleInputCount).toBe(2)
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
test.skip('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {

View File

@@ -0,0 +1,385 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import {
ExecutionTestHelper,
PreviewTestHelper
} from '../helpers/ExecutionTestHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Multi-node Execution Progress', () => {
test.describe.configure({ mode: 'serial' })
let executionHelper: ExecutionTestHelper
let previewHelper: PreviewTestHelper
test.beforeEach(async ({ comfyPage }) => {
executionHelper = new ExecutionTestHelper(comfyPage.page)
previewHelper = new PreviewTestHelper(comfyPage.page)
})
test.afterEach(async () => {
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Can track progress of multiple async nodes executing in parallel', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Get references to the async nodes
const sleepNode1 = await comfyPage.getNodeRefById(2)
const sleepNode2 = await comfyPage.getNodeRefById(3)
const progressNode = await comfyPage.getNodeRefById(4)
// Verify nodes are present
expect(sleepNode1).toBeDefined()
expect(sleepNode2).toBeDefined()
expect(progressNode).toBeDefined()
// Set up tracking for progress events
await executionHelper.setupEventTracking()
// Start execution
await comfyPage.queueButton.click()
// Wait for all three nodes (2, 3, 4) to show progress from real execution
const testId = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
const node2 = latestState.nodes['2']
const node3 = latestState.nodes['3']
const node4 = latestState.nodes['4']
// Check that all nodes have started executing
return (
node2 &&
node2.state === 'running' &&
node3 &&
node3.state === 'running' &&
node4 &&
node4.state === 'running'
)
},
testId,
{ timeout: 10000 }
)
// Wait for progress to be applied to all nodes in the graph
await executionHelper.waitForGraphNodeProgress([2, 3, 4])
// Check that all nodes show progress
const nodeProgress1 = await sleepNode1.getProperty('progress')
const nodeProgress2 = await sleepNode2.getProperty('progress')
const nodeProgress3 = await progressNode.getProperty('progress')
// Progress values should now be defined (exact values depend on timing)
expect(nodeProgress1).toBeDefined()
expect(nodeProgress2).toBeDefined()
expect(nodeProgress3).toBeDefined()
expect(nodeProgress1).toBeGreaterThanOrEqual(0)
expect(nodeProgress1).toBeLessThanOrEqual(1)
expect(nodeProgress2).toBeGreaterThanOrEqual(0)
expect(nodeProgress2).toBeLessThanOrEqual(1)
expect(nodeProgress3).toBeGreaterThanOrEqual(0)
expect(nodeProgress3).toBeLessThanOrEqual(1)
// Wait for at least one node to finish
await executionHelper.waitForNodeFinish()
// Wait for the finished node's progress to be cleared
const testId2 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
// Find which nodes are finished
const finishedNodeIds = Object.entries(latestState.nodes)
.filter(([_, node]: [string, any]) => node.state === 'finished')
.map(([id, _]) => id)
// Check that finished nodes have no progress in the graph
return finishedNodeIds.some((id) => {
const node = window['app'].graph.getNodeById(parseInt(id))
return node && node.progress === undefined
})
},
testId2,
{ timeout: 5000 }
)
// Get current state of nodes
const testId3 = executionHelper.getTestId()
const currentState = await comfyPage.page.evaluate((testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return null
const latestState = states[states.length - 1]
const graphNodes = {
'2': window['app'].graph.getNodeById(2),
'3': window['app'].graph.getNodeById(3),
'4': window['app'].graph.getNodeById(4)
}
return {
stateNodes: latestState.nodes,
graphProgress: {
'2': graphNodes['2']?.progress,
'3': graphNodes['3']?.progress,
'4': graphNodes['4']?.progress
}
}
}, testId3)
// Verify that finished nodes have no progress, running nodes have progress
if (currentState && currentState.stateNodes) {
Object.entries(currentState.stateNodes).forEach(
([nodeId, nodeState]: [string, any]) => {
const graphProgress = currentState.graphProgress[nodeId]
if (nodeState.state === 'finished') {
expect(graphProgress).toBeUndefined()
} else if (nodeState.state === 'running') {
expect(graphProgress).toBeDefined()
expect(graphProgress).toBeGreaterThanOrEqual(0)
expect(graphProgress).toBeLessThanOrEqual(1)
}
}
)
}
// Clean up by canceling execution
})
test('Updates visual state for multiple executing nodes', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Wait for the graph to be properly initialized
await comfyPage.page.waitForFunction(
() => {
return window['app']?.graph?.nodes?.length > 0
},
{ timeout: 5000 }
)
// Set up tracking for progress events
await executionHelper.setupEventTracking()
// Start execution
await comfyPage.queueButton.click()
// Wait for multiple nodes to start executing
await executionHelper.waitForRunningNodes(2)
// Wait for the progress to be applied to nodes
await executionHelper.waitForGraphNodeProgress([2, 3])
// Verify that nodes have progress set (indicates they are executing)
const nodeStates = await comfyPage.page.evaluate(() => {
const node2 = window['app'].graph.getNodeById(2)
const node3 = window['app'].graph.getNodeById(3)
return {
node2Progress: node2?.progress,
node3Progress: node3?.progress,
// Check if any nodes are marked as running by having progress
hasRunningNodes:
(node2?.progress !== undefined && node2?.progress >= 0) ||
(node3?.progress !== undefined && node3?.progress >= 0)
}
})
expect(nodeStates.node2Progress).toBeDefined()
expect(nodeStates.node3Progress).toBeDefined()
expect(nodeStates.hasRunningNodes).toBe(true)
// Wait for at least one node to finish
await executionHelper.waitForNodeFinish()
// Wait for progress updates to reflect the finished state
const testId4 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
// Find nodes by their state
const finishedNodes = Object.entries(latestState.nodes)
.filter(([_, node]: [string, any]) => node.state === 'finished')
.map(([id, _]) => parseInt(id))
const runningNodes = Object.entries(latestState.nodes)
.filter(([_, node]: [string, any]) => node.state === 'running')
.map(([id, _]) => parseInt(id))
// Check graph nodes match the state
const allFinishedCorrect = finishedNodes.every((id) => {
const node = window['app'].graph.getNodeById(id)
return node && node.progress === undefined
})
const allRunningCorrect = runningNodes.every((id) => {
const node = window['app'].graph.getNodeById(id)
return node && node.progress !== undefined && node.progress >= 0
})
return (
allFinishedCorrect && allRunningCorrect && finishedNodes.length > 0
)
},
testId4,
{ timeout: 5000 }
)
// Verify the final node states
const testId5 = executionHelper.getTestId()
const finalNodeStates = await comfyPage.page.evaluate((testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return null
const latestState = states[states.length - 1]
const node2 = window['app'].graph.getNodeById(2)
const node3 = window['app'].graph.getNodeById(3)
return {
node2State: latestState.nodes['2']?.state,
node3State: latestState.nodes['3']?.state,
node2Progress: node2?.progress,
node3Progress: node3?.progress
}
}, testId5)
// Verify finished nodes have no progress, running nodes have progress
if (finalNodeStates) {
if (finalNodeStates.node2State === 'finished') {
expect(finalNodeStates.node2Progress).toBeUndefined()
} else if (finalNodeStates.node2State === 'running') {
expect(finalNodeStates.node2Progress).toBeDefined()
}
if (finalNodeStates.node3State === 'finished') {
expect(finalNodeStates.node3Progress).toBeUndefined()
} else if (finalNodeStates.node3State === 'running') {
expect(finalNodeStates.node3Progress).toBeDefined()
}
}
})
test('Clears previews when nodes start executing', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Initialize tracking for revoked previews
await comfyPage.page.evaluate(() => {
window['__revokedNodes'] = []
})
// Set up some fake previews
await comfyPage.page.evaluate(() => {
window['app'].nodePreviewImages['2'] = ['fake-preview-url-1']
window['app'].nodePreviewImages['3'] = ['fake-preview-url-2']
})
// Verify previews exist
const previewsBefore = await comfyPage.page.evaluate(() => {
return {
node2: window['app'].nodePreviewImages['2'],
node3: window['app'].nodePreviewImages['3']
}
})
expect(previewsBefore.node2).toEqual(['fake-preview-url-1'])
expect(previewsBefore.node3).toEqual(['fake-preview-url-2'])
// Mock revokePreviews to track calls and set up event listeners
await previewHelper.setupPreviewTracking()
await executionHelper.setupEventTracking()
// Start real execution using command
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Wait for real execution to trigger progress events that clear previews
const testId6 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
// Check if we have progress for nodes 2 and 3
const hasNode2Progress = states.some(
(state: any) =>
state.nodes &&
state.nodes['2'] &&
state.nodes['2'].state === 'running'
)
const hasNode3Progress = states.some(
(state: any) =>
state.nodes &&
state.nodes['3'] &&
state.nodes['3'].state === 'running'
)
return hasNode2Progress && hasNode3Progress
},
testId6,
{ timeout: 10000 }
)
// Wait for the event to be processed and previews to be revoked
await comfyPage.page.waitForFunction(
() => {
const revokedNodes = window['__revokedNodes']
const node2PreviewCleared =
window['app'].nodePreviewImages['2'] === undefined
const node3PreviewCleared =
window['app'].nodePreviewImages['3'] === undefined
return (
revokedNodes.includes('2') &&
revokedNodes.includes('3') &&
node2PreviewCleared &&
node3PreviewCleared
)
},
{ timeout: 5000 }
)
// Check that revokePreviews was called for both nodes
const revokedNodes = await previewHelper.getRevokedNodes()
expect(revokedNodes).toContain('2')
expect(revokedNodes).toContain('3')
// Check that previews were cleared
const previewsAfter = await comfyPage.page.evaluate(() => {
return {
node2: window['app'].nodePreviewImages['2'],
node3: window['app'].nodePreviewImages['3']
}
})
expect(previewsAfter.node2).toBeUndefined()
expect(previewsAfter.node3).toBeUndefined()
})
})

View File

@@ -0,0 +1,168 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import {
ExecutionTestHelper,
PreviewTestHelper
} from '../helpers/ExecutionTestHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Multi-node Execution Progress - Simple', () => {
test.describe.configure({ mode: 'serial' })
let executionHelper: ExecutionTestHelper
let previewHelper: PreviewTestHelper
test.beforeEach(async ({ comfyPage }) => {
executionHelper = new ExecutionTestHelper(comfyPage.page)
previewHelper = new PreviewTestHelper(comfyPage.page)
})
test.afterEach(async () => {
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Can dispatch and receive progress_state events', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Set up event tracking
await executionHelper.setupEventTracking()
// Start real execution
await comfyPage.queueButton.click()
// Wait for real progress_state events from backend
const testId = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
return latestState.nodes && Object.keys(latestState.nodes).length > 0
},
testId,
{ timeout: 10000 }
)
// Get the captured states
const eventState = await executionHelper.getEventState()
const result = eventState.progressStates
// Should have captured real events
expect(result.length).toBeGreaterThan(0)
const firstState = result[0]
expect(firstState).toBeDefined()
expect(firstState.prompt_id).toBeDefined()
expect(firstState.nodes).toBeDefined()
// Check that we got real node progress
const nodeIds = Object.keys(firstState.nodes)
expect(nodeIds.length).toBeGreaterThan(0)
// Verify node structure
for (const nodeId of nodeIds) {
const node = firstState.nodes[nodeId]
expect(node.state).toBeDefined()
expect(node.node_id).toBeDefined()
expect(node.display_node_id).toBeDefined()
expect(node.prompt_id).toBeDefined()
}
})
test('Canvas updates when nodes have progress', async ({ comfyPage, ws }) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Set up progress tracking
await executionHelper.setupEventTracking()
// Start real execution
await comfyPage.queueButton.click()
// Wait for nodes to have progress from real execution
const testId2 = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
// Check if any nodes are running with progress
const runningNodes = Object.values(latestState.nodes).filter(
(node: any) => node.state === 'running' && node.value > 0
)
return runningNodes.length > 0
},
testId2,
{ timeout: 10000 }
)
// Wait for progress to be applied to graph nodes
await executionHelper.waitForGraphNodeProgress([2, 3, 4])
// Check that nodes have progress set from real execution
const node2Progress = await executionHelper.getGraphNodeProgress(2)
const node3Progress = await executionHelper.getGraphNodeProgress(3)
const node4Progress = await executionHelper.getGraphNodeProgress(4)
// At least one node should have progress
const hasProgress =
(node2Progress !== undefined && node2Progress > 0) ||
(node3Progress !== undefined && node3Progress > 0) ||
(node4Progress !== undefined && node4Progress > 0)
expect(hasProgress).toBe(true)
})
test('Preview events include metadata', async ({ comfyPage, ws }) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Track preview events
await previewHelper.setupPreviewTracking()
await executionHelper.setupEventTracking()
// Start real execution using command
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// For this test, we'll check the event structure by simulating one
// since real preview events depend on the workflow actually generating images
await comfyPage.page.evaluate(() => {
const api = window['app'].api
// Simulate a preview event that would come from backend
api.dispatchCustomEvent('b_preview_with_metadata', {
blob: new Blob(['test'], { type: 'image/png' }),
nodeId: '10:5:3',
displayNodeId: '10',
parentNodeId: '10:5',
realNodeId: '3',
promptId: 'test-prompt-id'
})
})
await comfyPage.nextFrame()
// Check captured events
const captured = await previewHelper.getPreviewEvents()
expect(captured).toHaveLength(1)
expect(captured[0]).toEqual({
nodeId: '10:5:3',
displayNodeId: '10',
parentNodeId: '10:5',
realNodeId: '3',
promptId: 'test-prompt-id'
})
})
})

View File

@@ -0,0 +1,272 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import {
ExecutionTestHelper,
PreviewTestHelper
} from '../helpers/ExecutionTestHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Preview with Metadata', () => {
test.describe.configure({ mode: 'serial' })
let executionHelper: ExecutionTestHelper
let previewHelper: PreviewTestHelper
test.beforeEach(async ({ comfyPage }) => {
executionHelper = new ExecutionTestHelper(comfyPage.page)
previewHelper = new PreviewTestHelper(comfyPage.page)
})
test.afterEach(async () => {
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Handles b_preview_with_metadata event correctly', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Clear any existing previews
await comfyPage.page.evaluate(() => {
window['app'].nodePreviewImages = {}
})
// Set up handler to track preview events and execution
await executionHelper.setupEventTracking()
await comfyPage.page.evaluate(() => {
window['__previewHandled'] = false
const api = window['app'].api
// Add handler to track preview events
api.addEventListener('b_preview_with_metadata', (event) => {
const { displayNodeId, blob } = event.detail
// Create URL from the blob in the event
const url = URL.createObjectURL(blob)
window['app'].nodePreviewImages[displayNodeId] = [url]
window['__previewHandled'] = true
window['__lastPreviewUrl'] = url
})
})
// Start real execution to test event handling in context
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Trigger b_preview_with_metadata event (simulating what backend would send)
await comfyPage.page.evaluate(() => {
const api = window['app'].api
const event = new CustomEvent('b_preview_with_metadata', {
detail: {
blob: new Blob(['test'], { type: 'image/png' }),
nodeId: '2',
displayNodeId: '2',
parentNodeId: '2',
realNodeId: '2',
promptId: 'test-prompt-id'
}
})
api.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Wait for preview to be handled
await comfyPage.page.waitForFunction(
() => window['__previewHandled'] === true,
{ timeout: 5000 }
)
// Check that preview was set for the correct node
const result = await comfyPage.page.evaluate(() => {
return {
previewImages: window['app'].nodePreviewImages,
lastUrl: window['__lastPreviewUrl']
}
})
expect(result.previewImages['2']).toBeDefined()
expect(result.previewImages['2']).toHaveLength(1)
expect(result.previewImages['2'][0]).toBe(result.lastUrl)
})
test('Clears old previews when new preview arrives', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Set up initial preview
const initialBlobUrl = await comfyPage.page.evaluate(() => {
const blob = new Blob(['initial image'], { type: 'image/png' })
const url = URL.createObjectURL(blob)
window['app'].nodePreviewImages['3'] = [url]
return url
})
// Create spy to track URL revocations
await previewHelper.setupPreviewTracking()
// Mock the handler to revoke old previews
await comfyPage.page.evaluate(() => {
const api = window['app'].api
api.addEventListener('b_preview_with_metadata', (event) => {
const { displayNodeId } = event.detail
window['app'].revokePreviews(displayNodeId)
const newBlob = new Blob(['new image'], { type: 'image/png' })
const newUrl = URL.createObjectURL(newBlob)
window['app'].nodePreviewImages[displayNodeId] = [newUrl]
})
})
// Trigger new preview for same node
await comfyPage.page.evaluate(() => {
const api = window['app'].api
const event = new CustomEvent('b_preview_with_metadata', {
detail: {
blob: new Blob(['new image'], { type: 'image/png' }),
nodeId: '3',
displayNodeId: '3',
parentNodeId: '3',
realNodeId: '3',
promptId: 'test-prompt-id'
}
})
api.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Check that old URL was revoked
const finalRevokedUrls = await previewHelper.getRevokedUrls()
expect(finalRevokedUrls).toContain(initialBlobUrl)
// Check that new preview replaced old one
const newPreviewImages = await previewHelper.getNodePreviews('3')
expect(newPreviewImages).toHaveLength(1)
expect(newPreviewImages[0]).not.toBe(initialBlobUrl)
})
test('Associates preview with correct display node in subgraph', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Mock handler that stores metadata
await previewHelper.setupPreviewTracking()
await comfyPage.page.evaluate(() => {
window['__previewMetadata'] = {}
const api = window['app'].api
api.addEventListener('b_preview_with_metadata', (event) => {
const { displayNodeId, nodeId, parentNodeId, realNodeId, promptId } =
event.detail
window['__previewMetadata'][displayNodeId] = {
nodeId,
displayNodeId,
parentNodeId,
realNodeId,
promptId
}
// Still create the preview
const url = URL.createObjectURL(event.detail.blob)
window['app'].nodePreviewImages[displayNodeId] = [url]
})
})
// Simulate preview from a subgraph node
await comfyPage.page.evaluate(() => {
const api = window['app'].api
const event = new CustomEvent('b_preview_with_metadata', {
detail: {
blob: new Blob(['subgraph preview'], { type: 'image/png' }),
nodeId: '10:5:3', // Nested execution ID
displayNodeId: '10', // Top-level display node
parentNodeId: '10:5', // Parent subgraph
realNodeId: '3', // Actual node ID within subgraph
promptId: 'test-prompt-id'
}
})
api.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Check that preview is associated with display node
const metadata = await comfyPage.page.evaluate(
() => window['__previewMetadata']
)
expect(metadata['10']).toBeDefined()
expect(metadata['10'].nodeId).toBe('10:5:3')
expect(metadata['10'].displayNodeId).toBe('10')
expect(metadata['10'].parentNodeId).toBe('10:5')
expect(metadata['10'].realNodeId).toBe('3')
// Check that preview exists for display node
const previews = await comfyPage.page.evaluate(
() => window['app'].nodePreviewImages
)
expect(previews['10']).toBeDefined()
expect(previews['10']).toHaveLength(1)
})
test('Maintains backward compatibility with b_preview event', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
// Track both events
const eventsFired = await comfyPage.page.evaluate(() => {
const events: string[] = []
const api = window['app'].api
api.addEventListener('b_preview', () => {
events.push('b_preview')
})
api.addEventListener('b_preview_with_metadata', () => {
events.push('b_preview_with_metadata')
})
window['__eventsFired'] = events
return events
})
// Trigger b_preview_with_metadata
await comfyPage.page.evaluate(() => {
const api = window['app'].api
const blob = new Blob(['test image'], { type: 'image/png' })
// Simulate the API behavior
api.dispatchCustomEvent('b_preview_with_metadata', {
blob,
nodeId: '2',
displayNodeId: '2',
parentNodeId: '2',
realNodeId: '2',
promptId: 'test-prompt-id'
})
// Also dispatch legacy event as the API would
api.dispatchCustomEvent('b_preview', blob)
})
await comfyPage.nextFrame()
// Check that both events were fired
const finalEvents = await comfyPage.page.evaluate(
() => window['__eventsFired']
)
expect(finalEvents).toContain('b_preview_with_metadata')
expect(finalEvents).toContain('b_preview')
})
})

View File

@@ -0,0 +1,368 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Release Notifications', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('should show help center with release information', async ({
comfyPage
}) => {
// Mock release API with test data instead of empty array
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'medium',
content:
'## New Features\n\n- Added awesome feature\n- Fixed important bug',
published_at: new Date().toISOString()
}
])
})
} else {
await route.continue()
}
})
// Setup with release mocking disabled for this test
await comfyPage.setup({ mockReleases: false })
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify help center menu appears
const helpMenu = comfyPage.page.locator('.help-center-menu')
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section shows the release
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show the release version
await expect(
whatsNewSection.locator('text=Comfy v0.3.44 Release')
).toBeVisible()
// Close help center by dismissable mask
await comfyPage.page.click('.help-center-backdrop')
await expect(helpMenu).not.toBeVisible()
})
test('should not show release notifications when mocked (default behavior)', async ({
comfyPage
}) => {
// Use default setup (mockReleases: true)
await comfyPage.setup()
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify help center menu appears
const helpMenu = comfyPage.page.locator('.help-center-menu')
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section shows no releases
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show "No recent releases" message
await expect(
whatsNewSection.locator('text=No recent releases')
).toBeVisible()
// Should not show any popups or toasts
await expect(comfyPage.page.locator('.whats-new-popup')).not.toBeVisible()
await expect(
comfyPage.page.locator('.release-notification-toast')
).not.toBeVisible()
})
test('should handle release API errors gracefully', async ({ comfyPage }) => {
// Mock API to return an error
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Server error' })
})
} else {
await route.continue()
}
})
// Setup with release mocking disabled
await comfyPage.setup({ mockReleases: false })
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify help center still works despite API error
const helpMenu = comfyPage.page.locator('.help-center-menu')
await expect(helpMenu).toBeVisible()
// Should show no releases due to error
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(
whatsNewSection.locator('text=No recent releases')
).toBeVisible()
})
test('should hide "What\'s New" section when notifications are disabled', async ({
comfyPage
}) => {
// Disable version update notifications
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
// Mock release API with test data
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'high',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString()
}
])
})
} else {
await route.continue()
}
})
await comfyPage.setup({ mockReleases: false })
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify help center menu appears
const helpMenu = comfyPage.page.locator('.help-center-menu')
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section is hidden
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).not.toBeVisible()
// Should not show any popups or toasts
await expect(comfyPage.page.locator('.whats-new-popup')).not.toBeVisible()
await expect(
comfyPage.page.locator('.release-notification-toast')
).not.toBeVisible()
})
test('should not make API calls when notifications are disabled', async ({
comfyPage
}) => {
// Disable version update notifications
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
// Track API calls
let apiCallCount = 0
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
apiCallCount++
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
} else {
await route.continue()
}
})
await comfyPage.setup({ mockReleases: false })
// Wait a bit to ensure any potential API calls would have been made
await comfyPage.page.waitForTimeout(1000)
// Verify no API calls were made
expect(apiCallCount).toBe(0)
})
test('should show "What\'s New" section when notifications are enabled', async ({
comfyPage
}) => {
// Enable version update notifications (default behavior)
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', true)
// Mock release API with test data
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'medium',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString()
}
])
})
} else {
await route.continue()
}
})
await comfyPage.setup({ mockReleases: false })
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify help center menu appears
const helpMenu = comfyPage.page.locator('.help-center-menu')
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section is visible
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show the release
await expect(
whatsNewSection.locator('text=Comfy v0.3.44 Release')
).toBeVisible()
})
test('should toggle "What\'s New" section when setting changes', async ({
comfyPage
}) => {
// Mock release API with test data
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'low',
content: '## Bug Fixes\n\n- Fixed minor issue',
published_at: new Date().toISOString()
}
])
})
} else {
await route.continue()
}
})
// Start with notifications enabled
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', true)
await comfyPage.setup({ mockReleases: false })
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify "What's New?" section is visible
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Close help center
await comfyPage.page.click('.help-center-backdrop')
// Disable notifications
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
// Reopen help center
await helpCenterButton.click()
// Verify "What's New?" section is now hidden
await expect(whatsNewSection).not.toBeVisible()
})
test('should handle edge case with empty releases and disabled notifications', async ({
comfyPage
}) => {
// Disable notifications
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
// Mock empty releases
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
} else {
await route.continue()
}
})
await comfyPage.setup({ mockReleases: false })
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify help center still works
const helpMenu = comfyPage.page.locator('.help-center-menu')
await expect(helpMenu).toBeVisible()
// Section should be hidden regardless of empty releases
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).not.toBeVisible()
})
})

View File

@@ -24,7 +24,7 @@ test.describe('Canvas Right Click Menu', () => {
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test('Can convert to group node', async ({ comfyPage }) => {
test.skip('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.rightClickCanvas()

View File

@@ -0,0 +1,82 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph Breadcrumb Title Sync', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Breadcrumb updates when subgraph node title is changed', async ({
comfyPage
}) => {
// Load a workflow with subgraphs
await comfyPage.loadWorkflow('nested-subgraph')
await comfyPage.nextFrame()
// Get the subgraph node by ID (node 10 is the subgraph)
const subgraphNode = await comfyPage.getNodeRefById('10')
// Get node position and double-click on it to enter the subgraph
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y + nodeSize.height / 2 + 10
}
})
await comfyPage.nextFrame()
// Wait for breadcrumb to appear
await comfyPage.page.waitForSelector('.subgraph-breadcrumb', {
state: 'visible',
timeout: 20000
})
// Get initial breadcrumb text
const breadcrumb = comfyPage.page.locator('.subgraph-breadcrumb')
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back to main graph
await comfyPage.page.keyboard.press('Escape')
// Double-click on the title area of the subgraph node to edit
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10 // Title area is above the node body
},
delay: 5
})
// Wait for title editor to appear
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
// Clear existing text and type new title
await comfyPage.page.keyboard.press('Control+a')
const newTitle = 'Updated Subgraph Title'
await comfyPage.page.keyboard.type(newTitle)
await comfyPage.page.keyboard.press('Enter')
// Wait a frame for the update to complete
await comfyPage.nextFrame()
// Enter the subgraph again
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y + nodeSize.height / 2
},
delay: 5
})
// Wait for breadcrumb
await comfyPage.page.waitForSelector('.subgraph-breadcrumb')
// Check that breadcrumb now shows the new title
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(newTitle)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
})

View File

@@ -0,0 +1,237 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import {
ExecutionTestHelper,
SubgraphTestHelper
} from '../helpers/ExecutionTestHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('Subgraph Execution Progress', () => {
test.describe.configure({ mode: 'serial' })
test.setTimeout(30000) // Increase timeout for subgraph tests
let executionHelper: ExecutionTestHelper
let subgraphHelper: SubgraphTestHelper
test.beforeEach(async ({ comfyPage }) => {
executionHelper = new ExecutionTestHelper(comfyPage.page)
subgraphHelper = new SubgraphTestHelper(comfyPage.page)
// Share the same test ID to access the same window properties
subgraphHelper.setTestId(executionHelper.getTestId())
})
test.afterEach(async () => {
if (executionHelper) {
await executionHelper.cleanup()
}
})
test('Shows progress for nodes inside subgraphs', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
// Get reference to the subgraph node
const subgraphNode = await comfyPage.getNodeRefById(10)
expect(subgraphNode).toBeDefined()
// Set up tracking for progress events
await executionHelper.setupEventTracking()
// Start real execution using command to avoid click issues
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Wait for real progress events from subgraph execution
await subgraphHelper.waitForNestedNodeProgress(1)
// Wait for progress to be applied to the subgraph node
await executionHelper.waitForGraphNodeProgress([10])
// Check that the subgraph node shows aggregated progress
const subgraphProgress = await subgraphNode.getProperty('progress')
// The progress should be aggregated from child nodes
expect(subgraphProgress).toBeDefined()
expect(subgraphProgress).toBeGreaterThan(0)
expect(subgraphProgress).toBeLessThanOrEqual(1)
// Wait for stroke style to be applied
await comfyPage.page.waitForFunction(
(nodeId) => {
const node = window['app'].graph.getNodeById(nodeId)
if (!node?.strokeStyles?.['running']) return false
const style = node.strokeStyles['running'].call(node)
return style?.color === '#0f0'
},
10,
{ timeout: 5000 }
)
// Check stroke style
const strokeStyle = await comfyPage.page.evaluate((nodeId) => {
const node = window['app'].graph.getNodeById(nodeId)
if (!node || !node.strokeStyles || !node.strokeStyles['running']) {
return null
}
return node.strokeStyles['running'].call(node)
}, 10)
expect(strokeStyle).toEqual({ color: '#0f0' })
})
test('Handles deeply nested subgraph execution', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
// Set up tracking for progress events
await executionHelper.setupEventTracking()
// Start real execution using command to avoid click issues
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Wait for real progress events from deeply nested subgraph execution
await subgraphHelper.waitForNestedNodeProgress(2)
// Wait for progress to be applied to the top-level subgraph node
await executionHelper.waitForGraphNodeProgress([10])
// Check that top-level subgraph shows progress
const subgraphNode = await comfyPage.getNodeRefById(10)
const progress = await subgraphNode.getProperty('progress')
expect(progress).toBeDefined()
expect(progress).toBeGreaterThan(0)
expect(progress).toBeLessThanOrEqual(1)
})
test('Shows running state for parent nodes when child executes', async ({
comfyPage,
ws
}) => {
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
// Track which nodes have running stroke style
const getRunningNodes = async () => {
return await comfyPage.page.evaluate(() => {
const runningNodes: number[] = []
const nodes = window['app'].graph.nodes
for (const node of nodes) {
if (
node.strokeStyles?.['running'] &&
node.strokeStyles['running'].call(node)?.color === '#0f0'
) {
runningNodes.push(node.id)
}
}
return runningNodes
})
}
// Wait for any existing execution to complete
await comfyPage.page
.waitForFunction(
() => {
const nodes = window['app'].graph.nodes
for (const node of nodes) {
if (
node.strokeStyles?.['running'] &&
node.strokeStyles['running'].call(node)?.color === '#0f0'
) {
return false
}
}
return true
},
{ timeout: 10000 }
)
.catch(() => {
// If timeout, continue anyway
})
// Initially no nodes should be running
let runningNodes = await getRunningNodes()
expect(runningNodes).toHaveLength(0)
// Set up tracking for progress events
await executionHelper.setupEventTracking()
// Start real execution using command to avoid click issues
await comfyPage.executeCommand('Comfy.QueuePrompt')
// Wait for execution to start
await executionHelper.waitForExecutionStart()
// Wait for real nested node execution progress
await subgraphHelper.waitForNestedNodeProgress(1)
// Wait for parent subgraph to show as running
await comfyPage.page.waitForFunction(
(nodeId) => {
const node = window['app'].graph.getNodeById(nodeId)
if (!node?.strokeStyles?.['running']) return false
const style = node.strokeStyles['running'].call(node)
return style?.color === '#0f0'
},
10,
{ timeout: 5000 }
)
// Parent subgraph should show as running
runningNodes = await getRunningNodes()
expect(runningNodes).toContain(10)
// Wait for the execution to complete naturally
const testId = executionHelper.getTestId()
await comfyPage.page.waitForFunction(
(testId) => {
const states = window[`__progressStates_${testId}`]
if (!states || states.length === 0) return false
// Check if execution is finished (no more running nodes)
const latestState = states[states.length - 1]
if (!latestState.nodes) return false
const runningNodes = Object.values(latestState.nodes).filter(
(node: any) => node.state === 'running'
).length
return runningNodes === 0
},
testId,
{ timeout: 10000 }
)
// Add a small delay to ensure UI updates
await comfyPage.page.waitForTimeout(500)
// Wait for parent subgraph to no longer be running
await comfyPage.page.waitForFunction(
(nodeId) => {
const node = window['app'].graph.getNodeById(nodeId)
if (!node?.strokeStyles?.['running']) return true
const style = node.strokeStyles['running'].call(node)
return style?.color !== '#0f0'
},
10,
{ timeout: 5000 }
)
// Parent should no longer be running
runningNodes = await getRunningNodes()
expect(runningNodes).not.toContain(10)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 238 KiB

View File

@@ -0,0 +1,289 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Settings Search functionality', () => {
test.beforeEach(async ({ comfyPage }) => {
// Register test settings to verify hidden/deprecated filtering
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestSettingsExtension',
settings: [
{
id: 'TestHiddenSetting',
name: 'Test Hidden Setting',
type: 'hidden',
defaultValue: 'hidden_value',
category: ['Test', 'Hidden']
},
{
id: 'TestDeprecatedSetting',
name: 'Test Deprecated Setting',
type: 'text',
defaultValue: 'deprecated_value',
deprecated: true,
category: ['Test', 'Deprecated']
},
{
id: 'TestVisibleSetting',
name: 'Test Visible Setting',
type: 'text',
defaultValue: 'visible_value',
category: ['Test', 'Visible']
}
]
})
})
})
test('can open settings dialog and use search box', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await expect(searchBox).toBeVisible()
// Verify search box has the correct placeholder
await expect(searchBox).toHaveAttribute(
'placeholder',
expect.stringContaining('Search')
)
})
test('search box is functional and accepts input', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Comfy')
// Verify the input was accepted
await expect(searchBox).toHaveValue('Comfy')
})
test('search box clears properly', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('test')
await expect(searchBox).toHaveValue('test')
// Clear the search box
await searchBox.clear()
await expect(searchBox).toHaveValue('')
})
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Check that the sidebar has categories
const categories = comfyPage.page.locator(
'.settings-sidebar .p-listbox-option'
)
expect(await categories.count()).toBeGreaterThan(0)
// Check that at least one category is visible
await expect(categories.first()).toBeVisible()
})
test('can select different categories in sidebar', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Get categories and click on different ones
const categories = comfyPage.page.locator(
'.settings-sidebar .p-listbox-option'
)
const categoryCount = await categories.count()
if (categoryCount > 1) {
// Click on the second category
await categories.nth(1).click()
// Verify the category is selected
await expect(categories.nth(1)).toHaveClass(/p-listbox-option-selected/)
}
})
test('settings content area is visible', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Check that the content area is visible
const contentArea = comfyPage.page.locator('.settings-content')
await expect(contentArea).toBeVisible()
// Check that tab panels are visible
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
await expect(tabPanels).toBeVisible()
})
test('search functionality affects UI state', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
// Type in search box
await searchBox.fill('graph')
await comfyPage.page.waitForTimeout(200) // Wait for debounce
// Verify that the search input is handled
await expect(searchBox).toHaveValue('graph')
})
test('settings dialog can be closed', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Close with escape key
await comfyPage.page.keyboard.press('Escape')
// Verify dialog is closed
await expect(settingsDialog).not.toBeVisible()
})
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Type rapidly in search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('a')
await searchBox.fill('ab')
await searchBox.fill('abc')
await searchBox.fill('abcd')
// Wait for debounce
await comfyPage.page.waitForTimeout(200)
// Verify final value
await expect(searchBox).toHaveValue('abcd')
})
test('search excludes hidden settings from results', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await comfyPage.page.waitForTimeout(300) // Wait for debounce
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not hidden setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Hidden Setting')
})
test('search excludes deprecated settings from results', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await comfyPage.page.waitForTimeout(300) // Wait for debounce
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not deprecated setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
})
test('search shows visible settings but excludes hidden and deprecated', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await comfyPage.page.waitForTimeout(300) // Wait for debounce
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should only show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
// Should not show hidden or deprecated settings
await expect(settingsContent).not.toContainText('Test Hidden Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
})
test('search by setting name excludes hidden and deprecated', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const searchBox = comfyPage.page.locator('.settings-search-box input')
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Search specifically for hidden setting by name
await searchBox.clear()
await searchBox.fill('Hidden')
await comfyPage.page.waitForTimeout(300)
// Should not show the hidden setting even when searching by name
await expect(settingsContent).not.toContainText('Test Hidden Setting')
// Search specifically for deprecated setting by name
await searchBox.clear()
await searchBox.fill('Deprecated')
await comfyPage.page.waitForTimeout(300)
// Should not show the deprecated setting even when searching by name
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
// Search for visible setting by name - should work
await searchBox.clear()
await searchBox.fill('Visible')
await comfyPage.page.waitForTimeout(300)
// Should show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
})
})

View File

@@ -1,59 +0,0 @@
import { Plugin } from 'vite'
/**
* Vite plugin that adds an alias export for Vue's createBaseVNode as createElementVNode.
*
* This plugin addresses compatibility issues where some components or libraries
* might be using the older createElementVNode function name instead of createBaseVNode.
* It modifies the Vue vendor chunk during build to add the alias export.
*
* @returns {Plugin} A Vite plugin that modifies the Vue vendor chunk exports
*/
export function addElementVnodeExportPlugin(): Plugin {
return {
name: 'add-element-vnode-export-plugin',
renderChunk(code, chunk, _options) {
if (chunk.name.startsWith('vendor-vue')) {
const exportRegex = /(export\s*\{)([^}]*)(\}\s*;?\s*)$/
const match = code.match(exportRegex)
if (match) {
const existingExports = match[2].trim()
const exportsArray = existingExports
.split(',')
.map((e) => e.trim())
.filter(Boolean)
const hasCreateBaseVNode = exportsArray.some((e) =>
e.startsWith('createBaseVNode')
)
const hasCreateElementVNode = exportsArray.some((e) =>
e.includes('createElementVNode')
)
if (hasCreateBaseVNode && !hasCreateElementVNode) {
const newExportStatement = `${match[1]} ${existingExports ? existingExports + ',' : ''} createBaseVNode as createElementVNode ${match[3]}`
const newCode = code.replace(exportRegex, newExportStatement)
console.log(
`[add-element-vnode-export-plugin] Added 'createBaseVNode as createElementVNode' export to vendor-vue chunk.`
)
return { code: newCode, map: null }
} else if (!hasCreateBaseVNode) {
console.warn(
`[add-element-vnode-export-plugin] Warning: 'createBaseVNode' not found in exports of vendor-vue chunk. Cannot add alias.`
)
}
} else {
console.warn(
`[add-element-vnode-export-plugin] Warning: Could not find expected export block format in vendor-vue chunk.`
)
}
}
return null
}
}
}

View File

@@ -1,9 +1,24 @@
import type { OutputOptions } from 'rollup'
import { HtmlTagDescriptor, Plugin } from 'vite'
import glob from 'fast-glob'
import fs from 'fs-extra'
import { dirname, join } from 'node:path'
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
interface VendorLibrary {
interface ImportMapSource {
name: string
pattern: RegExp
pattern: string | RegExp
entry: string
recursiveDependence?: boolean
override?: Record<string, Partial<ImportMapSource>>
}
const parseDeps = (root: string, pkg: string) => {
const pkgPath = join(root, 'node_modules', pkg, 'package.json')
if (fs.existsSync(pkgPath)) {
const content = fs.readFileSync(pkgPath, 'utf-8')
const pkg = JSON.parse(content)
return Object.keys(pkg.dependencies || {})
}
return []
}
/**
@@ -23,53 +38,89 @@ interface VendorLibrary {
* @returns {Plugin} A Vite plugin that generates and injects an import map
*/
export function generateImportMapPlugin(
vendorLibraries: VendorLibrary[]
importMapSources: ImportMapSource[]
): Plugin {
const importMapEntries: Record<string, string> = {}
const resolvedImportMapSources: Map<string, ImportMapSource> = new Map()
const assetDir = 'assets/lib'
let root: string
return {
name: 'generate-import-map-plugin',
// Configure manual chunks during the build process
configResolved(config) {
root = config.root
if (config.build) {
// Ensure rollupOptions exists
if (!config.build.rollupOptions) {
config.build.rollupOptions = {}
}
const outputOptions: OutputOptions = {
manualChunks: (id: string) => {
for (const lib of vendorLibraries) {
if (lib.pattern.test(id)) {
return `vendor-${lib.name}`
}
for (const source of importMapSources) {
resolvedImportMapSources.set(source.name, source)
if (source.recursiveDependence) {
const deps = parseDeps(root, source.name)
while (deps.length) {
const dep = deps.shift()!
const depSource = Object.assign({}, source, {
name: dep,
pattern: dep,
...source.override?.[dep]
})
resolvedImportMapSources.set(depSource.name, depSource)
const _deps = parseDeps(root, depSource.name)
deps.unshift(..._deps)
}
return null
},
// Disable minification of internal exports to preserve function names
minifyInternalExports: false
}
}
config.build.rollupOptions.output = outputOptions
const external: (string | RegExp)[] = []
for (const [, source] of resolvedImportMapSources) {
external.push(source.pattern)
}
config.build.rollupOptions.external = external
}
},
generateBundle(_options, bundle) {
for (const fileName in bundle) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && !chunk.isEntry) {
// Find matching vendor library by chunk name
const vendorLib = vendorLibraries.find(
(lib) => chunk.name === `vendor-${lib.name}`
)
generateBundle(_options) {
for (const [, source] of resolvedImportMapSources) {
if (source.entry) {
const moduleFile = join(source.name, source.entry)
const sourceFile = join(root, 'node_modules', moduleFile)
const targetFile = join(root, 'dist', assetDir, moduleFile)
if (vendorLib) {
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
importMapEntries[vendorLib.name] = relativePath
importMapEntries[source.name] =
'./' + normalizePath(join(assetDir, moduleFile))
console.log(
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${relativePath}'`
)
const targetDir = dirname(targetFile)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.copyFileSync(sourceFile, targetFile)
}
if (source.recursiveDependence) {
const files = glob.sync(['**/*.{js,mjs}'], {
cwd: join(root, 'node_modules', source.name)
})
for (const file of files) {
const moduleFile = join(source.name, file)
const sourceFile = join(root, 'node_modules', moduleFile)
const targetFile = join(root, 'dist', assetDir, moduleFile)
importMapEntries[normalizePath(join(source.name, dirname(file)))] =
'./' + normalizePath(join(assetDir, moduleFile))
const targetDir = dirname(targetFile)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.copyFileSync(sourceFile, targetFile)
}
}
}

View File

@@ -1,3 +1,2 @@
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
export { comfyAPIPlugin } from './comfyAPIPlugin'
export { generateImportMapPlugin } from './generateImportMapPlugin'

72
dangerfile.ts Normal file
View File

@@ -0,0 +1,72 @@
import { danger, fail } from 'danger'
// Check if we should run the checks
const shouldRunChecks = async () => {
const allChangedFiles = [
...danger.git.modified_files,
...danger.git.created_files
]
const srcChanges = allChangedFiles.filter((file) => file.startsWith('src/'))
if (srcChanges.length === 0) {
return false
}
// Check total lines changed in src files
let totalLinesChanged = 0
for (const file of srcChanges) {
const diff = await danger.git.diffForFile(file)
if (diff) {
// Count only lines with actual content (non-empty after trimming whitespace)
// This excludes empty lines and lines containing only spaces/tabs
const additions =
diff.added?.split('\n').filter((line) => line.trim()).length || 0
const deletions =
diff.removed?.split('\n').filter((line) => line.trim()).length || 0
totalLinesChanged += additions + deletions
}
}
return totalLinesChanged > 3
}
// Check if browser tests were updated
const checkBrowserTestCoverage = () => {
const allChangedFiles = [
...danger.git.modified_files,
...danger.git.created_files
]
const hasBrowserTestChanges = allChangedFiles.some(
(file) => file.startsWith('browser_tests/') && file.endsWith('.ts')
)
if (!hasBrowserTestChanges) {
fail(`🧪 **E2E Test Coverage Missing**
All changes should be covered under E2E testing. Please add or update browser tests.`)
}
}
// Check for screen recording in PR description
const checkScreenRecording = () => {
const description = danger.github.pr.body || ''
const hasRecording =
/github\.com\/user-attachments\/assets\/[a-f0-9-]+/i.test(description) ||
/youtube\.com\/watch|youtu\.be\//i.test(description)
if (!hasRecording) {
fail(`📹 **Visual Documentation Missing**
Please add a screen recording or screenshot:
- GitHub: Drag & drop media to PR description
- YouTube: Add YouTube link`)
}
}
// Run the checks only if conditions are met
shouldRunChecks().then((shouldRun) => {
if (shouldRun) {
checkBrowserTestCoverage()
checkScreenRecording()
}
})

View File

@@ -1,4 +1,5 @@
import pluginJs from '@eslint/js'
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import unusedImports from 'eslint-plugin-unused-imports'
import pluginVue from 'eslint-plugin-vue'
@@ -45,7 +46,8 @@ export default [
},
{
plugins: {
'unused-imports': unusedImports
'unused-imports': unusedImports,
'@intlify/vue-i18n': pluginI18n
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
@@ -53,7 +55,41 @@ export default [
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'unused-imports/no-unused-imports': 'error',
'vue/no-v-html': 'off'
'vue/no-v-html': 'off',
// i18n rules
'@intlify/vue-i18n/no-raw-text': [
'error',
{
// Ignore strings that are:
// 1. Less than 2 characters
// 2. Only symbols/numbers/whitespace (no letters)
// 3. Match specific patterns
ignorePattern:
'^[^a-zA-Z]*$|^.{0,1}$|^[\\w._%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$',
ignoreNodes: ['md-icon', 'v-icon', 'pre', 'code', 'script', 'style'],
// Brand names and technical terms that shouldn't be translated
ignoreText: [
'ComfyUI',
'GitHub',
'OpenAI',
'API',
'URL',
'JSON',
'YAML',
'GPU',
'CPU',
'RAM',
'GB',
'MB',
'KB',
'ms',
'fps',
'px',
'App Data:',
'App Path:'
]
}
]
}
}
]

818
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.23.4",
"version": "1.24.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -31,6 +31,7 @@
"@eslint/js": "^9.8.0",
"@executeautomation/playwright-mcp-server": "^1.0.5",
"@iconify/json": "^2.2.245",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.20.0",
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.52.0",
@@ -44,6 +45,7 @@
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.19",
"chalk": "^5.3.0",
"danger": "^13.0.4",
"eslint": "^9.12.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
@@ -76,7 +78,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.16.6",
"@comfyorg/litegraph": "^0.16.9",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

192
scripts/check-unused-i18n-keys.ts Executable file
View File

@@ -0,0 +1,192 @@
#!/usr/bin/env tsx
import { execSync } from 'child_process'
import * as fs from 'fs'
import { globSync } from 'glob'
interface LocaleData {
[key: string]: any
}
// Configuration
const SOURCE_PATTERNS = ['src/**/*.{js,ts,vue}', '!src/locales/**/*']
const IGNORE_PATTERNS = [
// Keys that might be dynamically constructed
/^commands\./, // Command definitions are loaded dynamically
/^settings\..*\.options\./, // Setting options are rendered dynamically
/^nodeDefs\./, // Node definitions are loaded from backend
/^templateWorkflows\./, // Template workflows are loaded dynamically
/^dataTypes\./, // Data types might be referenced dynamically
/^contextMenu\./, // Context menu items might be dynamic
/^color\./, // Color names might be used dynamically
// Auto-generated categories from collect-i18n-general.ts
/^menuLabels\./, // Menu labels generated from command labels
/^settingsCategories\./, // Settings categories generated from setting definitions
/^serverConfigItems\./, // Server config items generated from SERVER_CONFIG_ITEMS
/^serverConfigCategories\./, // Server config categories generated from config categories
/^nodeCategories\./, // Node categories generated from node definitions
// Setting option values that are dynamically generated
/\.options\./ // All setting options are rendered dynamically
]
// Get list of staged locale files
function getStagedLocaleFiles(): string[] {
try {
const output = execSync('git diff --cached --name-only --diff-filter=AM', {
encoding: 'utf-8'
})
return output
.split('\n')
.filter(
(file) => file.startsWith('src/locales/') && file.endsWith('.json')
)
} catch {
return []
}
}
// Extract all keys from a nested object
function extractKeys(obj: any, prefix = ''): string[] {
const keys: string[] = []
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
keys.push(...extractKeys(value, fullKey))
} else {
keys.push(fullKey)
}
}
return keys
}
// Get new keys added in staged files
function getNewKeysFromStagedFiles(stagedFiles: string[]): Set<string> {
const newKeys = new Set<string>()
for (const file of stagedFiles) {
try {
// Get the staged content
const stagedContent = execSync(`git show :${file}`, { encoding: 'utf-8' })
const stagedData: LocaleData = JSON.parse(stagedContent)
const stagedKeys = new Set(extractKeys(stagedData))
// Get the current HEAD content (if file exists)
let headKeys = new Set<string>()
try {
const headContent = execSync(`git show HEAD:${file}`, {
encoding: 'utf-8'
})
const headData: LocaleData = JSON.parse(headContent)
headKeys = new Set(extractKeys(headData))
} catch {
// File is new, all keys are new
}
// Find keys that are in staged but not in HEAD
stagedKeys.forEach((key) => {
if (!headKeys.has(key)) {
newKeys.add(key)
}
})
} catch (error) {
console.error(`Error processing ${file}:`, error)
}
}
return newKeys
}
// Check if a key should be ignored
function shouldIgnoreKey(key: string): boolean {
return IGNORE_PATTERNS.some((pattern) => pattern.test(key))
}
// Search for key usage in source files
function isKeyUsed(key: string, sourceFiles: string[]): boolean {
// Escape special regex characters
const escapeRegex = (str: string) =>
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const escapedKey = escapeRegex(key)
const lastPart = key.split('.').pop()
const escapedLastPart = lastPart ? escapeRegex(lastPart) : ''
// Common patterns for i18n key usage
const patterns = [
// Direct usage: $t('key'), t('key'), i18n.t('key')
new RegExp(`[t$]\\s*\\(\\s*['"\`]${escapedKey}['"\`]`, 'g'),
// With namespace: $t('g.key'), t('namespace.key')
new RegExp(`[t$]\\s*\\(\\s*['"\`][^'"]+\\.${escapedLastPart}['"\`]`, 'g'),
// Dynamic keys might reference parts of the key
new RegExp(`['"\`]${escapedKey}['"\`]`, 'g')
]
for (const file of sourceFiles) {
const content = fs.readFileSync(file, 'utf-8')
for (const pattern of patterns) {
if (pattern.test(content)) {
return true
}
}
}
return false
}
// Main function
async function checkNewUnusedKeys() {
const stagedLocaleFiles = getStagedLocaleFiles()
if (stagedLocaleFiles.length === 0) {
// No locale files staged, nothing to check
process.exit(0)
}
// Get all new keys from staged files
const newKeys = getNewKeysFromStagedFiles(stagedLocaleFiles)
if (newKeys.size === 0) {
// Silent success - no output needed
process.exit(0)
}
// Get all source files
const sourceFiles = globSync(SOURCE_PATTERNS)
// Check each new key
const unusedNewKeys: string[] = []
newKeys.forEach((key) => {
if (!shouldIgnoreKey(key) && !isKeyUsed(key, sourceFiles)) {
unusedNewKeys.push(key)
}
})
// Report results
if (unusedNewKeys.length > 0) {
console.log('\n⚠ Warning: Found unused NEW i18n keys:\n')
for (const key of unusedNewKeys.sort()) {
console.log(` - ${key}`)
}
console.log(`\n✨ Total unused new keys: ${unusedNewKeys.length}`)
console.log(
'\nThese keys were added but are not used anywhere in the codebase.'
)
console.log('Consider using them or removing them in a future update.')
// Changed from process.exit(1) to process.exit(0) for warning only
process.exit(0)
} else {
// Silent success - no output needed
}
}
// Run the check
checkNewUnusedKeys().catch((err) => {
console.error('Error checking unused keys:', err)
process.exit(1)
})

View File

@@ -27,6 +27,11 @@
--content-fg: #000;
--content-hover-bg: #adadad;
--content-hover-fg: #000;
/* Code styling colors for help menu*/
--code-text-color: rgba(0, 122, 255, 1);
--code-bg-color: rgba(96, 165, 250, 0.2);
--code-block-bg-color: rgba(60, 60, 60, 0.12);
}
@media (prefers-color-scheme: dark) {

View File

@@ -1,8 +1,5 @@
<template>
<div
v-if="workflowStore.isSubgraphActive"
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
>
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
<Breadcrumb
class="bg-transparent"
:home="home"
@@ -14,28 +11,30 @@
</template>
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import { computed } from 'vue'
import { useWorkflowService } from '@/services/workflowService'
import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const items = computed(() => {
if (!workflowStore.subgraphNamePath.length) return []
if (!navigationStore.navigationStack.length) return []
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
label: name,
command: async () => {
const workflow = workflowStore.getWorkflowByPath(name)
if (workflow) await workflowService.openWorkflow(workflow)
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(subgraph)
}
}))
})
@@ -43,7 +42,7 @@ const items = computed(() => {
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
command: async () => {
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -55,22 +54,32 @@ const handleItemClick = (event: MenuItemCommandEvent) => {
event.item.command?.(event)
}
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
)
})
</script>
<style>
.subgraph-breadcrumb {
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
color: #d26565;
user-select: none;
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
0 0 0.375rem #000;
}
}
</style>

View File

@@ -50,7 +50,7 @@
<Suspense v-for="panel in panels" :key="panel.node.key">
<component :is="panel.component" />
<template #fallback>
<div>Loading {{ panel.node.label }} panel...</div>
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
</template>
</Suspense>
</TabPanels>

View File

@@ -41,7 +41,8 @@
</div>
</div>
<div class="flex justify-between items-center mt-8">
<div class="flex justify-between items-center">
<h3>{{ $t('credits.activity') }}</h3>
<Button
:label="$t('credits.invoiceHistory')"
text
@@ -81,6 +82,8 @@
<Divider />
<UsageLogsTable ref="usageLogsTableRef" />
<div class="flex flex-row gap-2">
<Button
:label="$t('credits.faqs')"
@@ -108,10 +111,11 @@ import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -131,12 +135,23 @@ const authActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
const formattedLastUpdateTime = computed(() =>
authStore.lastBalanceUpdateTime
? authStore.lastBalanceUpdateTime.toLocaleString()
: ''
)
watch(
() => authStore.lastBalanceUpdateTime,
(newTime, oldTime) => {
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
usageLogsTableRef.value.refresh()
}
}
)
const handlePurchaseCreditsClick = () => {
dialogService.showTopUpCreditsDialog()
}

View File

@@ -98,7 +98,7 @@
@keydown.stop.prevent="captureKeybinding"
/>
<Message v-if="existingKeybindingOnCombo" severity="warn">
Keybinding already exists on
{{ $t('g.keybindingAlreadyExists') }}
<Tag
severity="secondary"
:value="existingKeybindingOnCombo.commandId"

View File

@@ -0,0 +1,399 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Badge from 'primevue/badge'
import Button from 'primevue/button'
import Column from 'primevue/column'
import PrimeVue from 'primevue/config'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import Tooltip from 'primevue/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { EventType } from '@/services/customerEventsService'
import UsageLogsTable from './UsageLogsTable.vue'
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
loading: boolean
error: string | null
events: any[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
dataTableFirst: number
tooltipContentMap: Map<string, string>
loadEvents: () => Promise<void>
refresh: () => Promise<void>
onPageChange: (event: { page: number }) => void
}
// Mock the customerEventsService
const mockCustomerEventsService = vi.hoisted(() => ({
getMyEvents: vi.fn(),
formatEventType: vi.fn(),
getEventSeverity: vi.fn(),
formatAmount: vi.fn(),
formatDate: vi.fn(),
hasAdditionalInfo: vi.fn(),
getTooltipContent: vi.fn(),
error: { value: null },
isLoading: { value: false }
}))
vi.mock('@/services/customerEventsService', () => ({
useCustomerEventsService: () => mockCustomerEventsService,
EventType: {
CREDIT_ADDED: 'credit_added',
ACCOUNT_CREATED: 'account_created',
API_USAGE_STARTED: 'api_usage_started',
API_USAGE_COMPLETED: 'api_usage_completed'
}
}))
// Create i18n instance
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
credits: {
eventType: 'Event Type',
details: 'Details',
time: 'Time',
additionalInfo: 'Additional Info',
added: 'Added',
accountInitialized: 'Account initialized',
model: 'Model'
}
}
}
})
describe('UsageLogsTable', () => {
const mockEventsResponse = {
events: [
{
event_id: 'event-1',
event_type: 'credit_added',
params: {
amount: 1000,
transaction_id: 'txn-123'
},
createdAt: '2024-01-01T10:00:00Z'
},
{
event_id: 'event-2',
event_type: 'api_usage_completed',
params: {
api_name: 'Image Generation',
model: 'sdxl-base',
duration: 5000
},
createdAt: '2024-01-02T10:00:00Z'
}
],
total: 2,
page: 1,
limit: 7,
totalPages: 1
}
beforeEach(() => {
vi.clearAllMocks()
// Setup default service mock implementations
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockCustomerEventsService.formatEventType.mockImplementation((type) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'Credits Added'
case EventType.ACCOUNT_CREATED:
return 'Account Created'
case EventType.API_USAGE_COMPLETED:
return 'API Usage'
default:
return type
}
})
mockCustomerEventsService.getEventSeverity.mockImplementation((type) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'success'
case EventType.ACCOUNT_CREATED:
return 'info'
case EventType.API_USAGE_COMPLETED:
return 'warning'
default:
return 'info'
}
})
mockCustomerEventsService.formatAmount.mockImplementation((amount) => {
if (!amount) return '0.00'
return (amount / 100).toFixed(2)
})
mockCustomerEventsService.formatDate.mockImplementation((dateString) => {
return new Date(dateString).toLocaleDateString()
})
mockCustomerEventsService.hasAdditionalInfo.mockImplementation((event) => {
const { amount, api_name, model, ...otherParams } = event.params || {}
return Object.keys(otherParams).length > 0
})
mockCustomerEventsService.getTooltipContent.mockImplementation(() => {
return '<strong>Transaction Id:</strong> txn-123'
})
mockCustomerEventsService.error.value = null
mockCustomerEventsService.isLoading.value = false
})
afterEach(() => {
vi.restoreAllMocks()
})
const mountComponent = (options = {}) => {
return mount(UsageLogsTable, {
global: {
plugins: [PrimeVue, i18n, createTestingPinia()],
components: {
DataTable,
Column,
Badge,
Button,
Message,
ProgressSpinner
},
directives: {
tooltip: Tooltip
}
},
...options
})
}
describe('loading states', () => {
it('shows loading spinner when loading is true', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = true
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(DataTable).exists()).toBe(false)
})
it('shows error message when error exists', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.error = 'Failed to load events'
vm.loading = false
await nextTick()
const messageComponent = wrapper.findComponent(Message)
expect(messageComponent.exists()).toBe(true)
expect(messageComponent.props('severity')).toBe('error')
expect(messageComponent.text()).toContain('Failed to load events')
})
it('shows data table when loaded successfully', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
// Wait for component to mount and load data
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Message).exists()).toBe(false)
})
})
describe('data rendering', () => {
it('renders events data correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const dataTable = wrapper.findComponent(DataTable)
expect(dataTable.props('value')).toEqual(mockEventsResponse.events)
expect(dataTable.props('rows')).toBe(7)
expect(dataTable.props('paginator')).toBe(true)
expect(dataTable.props('lazy')).toBe(true)
})
it('renders badge for event types correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const badges = wrapper.findAllComponents(Badge)
expect(badges.length).toBeGreaterThan(0)
// Check if formatEventType and getEventSeverity are called
expect(mockCustomerEventsService.formatEventType).toHaveBeenCalled()
expect(mockCustomerEventsService.getEventSeverity).toHaveBeenCalled()
})
it('renders different event details based on event type', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
// Check if formatAmount is called for credit_added events
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
})
it('renders tooltip buttons for events with additional info', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
expect(mockCustomerEventsService.hasAdditionalInfo).toHaveBeenCalled()
})
})
describe('pagination', () => {
it('handles page change correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
// Simulate page change
const dataTable = wrapper.findComponent(DataTable)
await dataTable.vm.$emit('page', { page: 1 })
expect(vm.pagination.page).toBe(1) // page + 1
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
page: 2,
limit: 7
})
})
it('calculates dataTableFirst correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.pagination = { page: 2, limit: 7, total: 20, totalPages: 3 }
await nextTick()
expect(vm.dataTableFirst).toBe(7) // (2-1) * 7
})
})
describe('tooltip functionality', () => {
it('generates tooltip content map correctly', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
mockCustomerEventsService.getTooltipContent.mockReturnValue(
'<strong>Test:</strong> value'
)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const tooltipMap = vm.tooltipContentMap
expect(tooltipMap.get('event-1')).toBe('<strong>Test:</strong> value')
})
it('excludes events without additional info from tooltip map', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const tooltipMap = vm.tooltipContentMap
expect(tooltipMap.size).toBe(0)
})
})
describe('component methods', () => {
it('exposes refresh method', () => {
const wrapper = mountComponent()
expect(typeof wrapper.vm.refresh).toBe('function')
})
it('resets to first page on refresh', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.pagination.page = 3
await vm.refresh()
expect(vm.pagination.page).toBe(1)
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
page: 1,
limit: 7
})
})
})
describe('component lifecycle', () => {
it('initializes with correct default values', () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
expect(vm.events).toEqual([])
expect(vm.loading).toBe(true)
expect(vm.error).toBeNull()
expect(vm.pagination).toEqual({
page: 1,
limit: 7,
total: 0,
totalPages: 0
})
})
})
describe('EventType integration', () => {
it('uses EventType enum in template conditions', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = [
{
event_id: 'event-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
]
await nextTick()
// Verify that the component can access EventType enum
expect(EventType.CREDIT_ADDED).toBe('credit_added')
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
})
})
})

View File

@@ -0,0 +1,188 @@
<template>
<div>
<div v-if="loading" class="flex items-center justify-center p-8">
<ProgressSpinner />
</div>
<div v-else-if="error" class="p-4">
<Message severity="error" :closable="false">{{ error }}</Message>
</div>
<DataTable
v-else
:value="events"
:paginator="true"
:rows="pagination.limit"
:total-records="pagination.total"
:first="dataTableFirst"
:lazy="true"
class="p-datatable-sm custom-datatable"
@page="onPageChange"
>
<Column field="event_type" :header="$t('credits.eventType')">
<template #body="{ data }">
<Badge
:value="customerEventService.formatEventType(data.event_type)"
:severity="customerEventService.getEventSeverity(data.event_type)"
/>
</template>
</Column>
<Column field="details" :header="$t('credits.details')">
<template #body="{ data }">
<div class="event-details">
<!-- Credits Added -->
<template v-if="data.event_type === EventType.CREDIT_ADDED">
<div class="text-green-500 font-semibold">
{{ $t('credits.added') }} ${{
customerEventService.formatAmount(data.params?.amount)
}}
</div>
</template>
<!-- Account Created -->
<template v-else-if="data.event_type === EventType.ACCOUNT_CREATED">
<div>{{ $t('credits.accountInitialized') }}</div>
</template>
<!-- API Usage -->
<template
v-else-if="data.event_type === EventType.API_USAGE_COMPLETED"
>
<div class="flex flex-col gap-1">
<div class="font-semibold">
{{ data.params?.api_name || 'API' }}
</div>
<div class="text-sm text-gray-400">
{{ $t('credits.model') }}: {{ data.params?.model || '-' }}
</div>
</div>
</template>
</div>
</template>
</Column>
<Column field="createdAt" :header="$t('credits.time')">
<template #body="{ data }">
{{ customerEventService.formatDate(data.createdAt) }}
</template>
</Column>
<Column field="params" :header="$t('credits.additionalInfo')">
<template #body="{ data }">
<Button
v-if="customerEventService.hasAdditionalInfo(data)"
v-tooltip.top="{
escape: false,
value: tooltipContentMap.get(data.event_id) || '',
pt: {
text: {
style: {
width: 'max-content !important'
}
}
}
}"
icon="pi pi-info-circle"
class="p-button-text p-button-sm"
/>
</template>
</Column>
</DataTable>
</div>
</template>
<script setup lang="ts">
import Badge from 'primevue/badge'
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import {
AuditLog,
EventType,
useCustomerEventsService
} from '@/services/customerEventsService'
const events = ref<AuditLog[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const customerEventService = useCustomerEventsService()
const pagination = ref({
page: 1,
limit: 7,
total: 0,
totalPages: 0
})
const dataTableFirst = computed(
() => (pagination.value.page - 1) * pagination.value.limit
)
const tooltipContentMap = computed(() => {
const map = new Map<string, string>()
events.value.forEach((event) => {
if (customerEventService.hasAdditionalInfo(event) && event.event_id) {
map.set(event.event_id, customerEventService.getTooltipContent(event))
}
})
return map
})
const loadEvents = async () => {
loading.value = true
error.value = null
try {
const response = await customerEventService.getMyEvents({
page: pagination.value.page,
limit: pagination.value.limit
})
if (response) {
if (response.events) {
events.value = response.events
}
if (response.page) {
pagination.value.page = response.page
}
if (response.limit) {
pagination.value.limit = response.limit
}
if (response.total) {
pagination.value.total = response.total
}
if (response.totalPages) {
pagination.value.totalPages = response.totalPages
}
} else {
error.value = customerEventService.error.value || 'Failed to load events'
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
console.error('Error loading events:', err)
} finally {
loading.value = false
}
}
const onPageChange = (event: { page: number }) => {
pagination.value.page = event.page + 1
loadEvents().catch((error) => {
console.error('Error loading events:', error)
})
}
const refresh = async () => {
pagination.value.page = 1
await loadEvents()
}
defineExpose({
refresh
})
</script>

View File

@@ -22,7 +22,7 @@
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-xs font-bold text-neutral-600">
{{ comfyManagerStore.uncompletedCount }} of
{{ comfyManagerStore.uncompletedCount }} {{ $t('g.progressCountOf') }}
{{ comfyManagerStore.taskLogs.length }}
</span>
<div class="flex items-center">

View File

@@ -21,16 +21,14 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgetStates = computed(() =>
Array.from(domWidgetStore.widgetStates.values())
)
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const lowQuality = lgCanvas.low_quality
for (const widgetState of domWidgetStore.widgetStates.values()) {
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
const node = widget.node as LGraphNode

View File

@@ -12,10 +12,12 @@
<BottomPanel />
</template>
<template #graph-canvas-panel>
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
class="pointer-events-auto"
/>
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
/>
<SubgraphBreadcrumb />
</div>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
</template>
</LiteGraphCanvasSplitterOverlay>
@@ -39,12 +41,11 @@
</SelectionOverlay>
<DomWidgets />
</template>
<SubgraphBreadcrumb />
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { useEventListener } from '@vueuse/core'
import { useEventListener, whenever } from '@vueuse/core'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
@@ -71,12 +72,12 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { newUserService } from '@/services/newUserService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -84,6 +85,7 @@ import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -189,22 +191,26 @@ watch(
}
)
// Update the progress of the executing node
// Update the progress of executing nodes
watch(
() =>
[executionStore.executingNodeId, executionStore.executingNodeProgress] as [
NodeId | null,
number | null
],
([executingNodeId, executingNodeProgress]) => {
for (const node of comfyApp.graph.nodes) {
if (node.id == executingNodeId) {
node.progress = executingNodeProgress ?? undefined
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
([nodeLocationProgressStates, canvas]) => {
if (!canvas?.graph) return
for (const node of canvas.graph.nodes) {
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(node.id)
const progressState = nodeLocationProgressStates[nodeLocatorId]
if (progressState && progressState.state === 'running') {
node.progress = progressState.value / progressState.max
} else {
node.progress = undefined
}
}
}
// Force canvas redraw to ensure progress updates are visible
canvas.graph.setDirtyCanvas(true, false)
},
{ deep: true }
)
// Update node slot errors
@@ -300,6 +306,9 @@ onMounted(async () => {
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
})
await newUserService().initializeIfNewUser(settingStore)
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
@@ -325,6 +334,11 @@ onMounted(async () => {
await workflowPersistence.restorePreviousWorkflow()
workflowPersistence.restoreWorkflowTabsState()
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } = await import('@/stores/releaseStore')
const releaseStore = useReleaseStore()
void releaseStore.initialize()
// Start watching for locale change after the initial value is loaded.
watch(
() => settingStore.get('Comfy.Locale'),
@@ -334,6 +348,16 @@ onMounted(async () => {
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
},
{ immediate: true }
)
emit('ready')
})
</script>

View File

@@ -12,6 +12,7 @@
<PinButton />
<EditModelButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<DeleteButton />
<RefreshButton />
<ExtensionCommandButton
@@ -29,6 +30,7 @@ import { computed } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import EditModelButton from '@/components/graph/selectionToolbox/EditModelButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'

View File

@@ -41,7 +41,15 @@ const previousCanvasDraggable = ref(true)
const onEdit = (newValue: string) => {
if (titleEditorStore.titleEditorTarget && newValue.trim() !== '') {
titleEditorStore.titleEditorTarget.title = newValue.trim()
const trimmedTitle = newValue.trim()
titleEditorStore.titleEditorTarget.title = trimmedTitle
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
const target = titleEditorStore.titleEditorTarget
if (target instanceof LGraphNode && target.isSubgraphNode?.()) {
target.subgraph.name = trimmedTitle
}
app.graph.setDirtyCanvas(true, true)
}
showInput.value = false

View File

@@ -0,0 +1,34 @@
<template>
<Button
v-show="isVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-box"
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isVisible = computed(() => {
return (
canvasStore.groupSelected ||
canvasStore.rerouteSelected ||
canvasStore.nodeSelected
)
})
</script>

View File

@@ -1,5 +1,6 @@
<template>
<Button
v-show="isDeletable"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
@@ -13,10 +14,17 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isDeletable = computed(() =>
canvasStore.selectedItems.some((x) => x.removable !== false)
)
</script>

View File

@@ -25,8 +25,9 @@ const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isSingleImageNode = computed(() => {
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
return nodes.length === 1 && nodes.some(isImageNode)
const { selectedItems } = canvasStore
const item = selectedItems[0]
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
})
const openMaskEditor = () => {

View File

@@ -20,33 +20,44 @@ import { useExecutionStore } from '@/stores/executionStore'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
defineProps<{
const props = defineProps<{
widget?: object
nodeId: NodeId
}>()
const executionStore = useExecutionStore()
const isParentNodeExecuting = ref(true)
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
let executingNodeId: NodeId | null = null
let parentNodeId: NodeId | null = null
onMounted(() => {
executingNodeId = executionStore.executingNodeId
// Get the parent node ID from props if provided
// For backward compatibility, fall back to the first executing node
parentNodeId = props.nodeId
})
// Watch for either a new node has starting execution or overall execution ending
const stopWatching = watch(
[() => executionStore.executingNode, () => executionStore.isIdle],
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
() => {
if (executionStore.isIdle) {
isParentNodeExecuting.value = false
stopWatching()
return
}
// Check if parent node is no longer in the executing nodes list
if (
executionStore.isIdle ||
(executionStore.executingNode &&
executionStore.executingNode.id !== executingNodeId)
parentNodeId &&
!executionStore.executingNodeIds.includes(parentNodeId)
) {
isParentNodeExecuting.value = false
stopWatching()
}
if (!executingNodeId) {
executingNodeId = executionStore.executingNodeId
// Set parent node ID if not set yet
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
parentNodeId = executionStore.executingNodeIds[0]
}
}
)

View File

@@ -0,0 +1,645 @@
<template>
<div class="help-center-menu" role="menu" aria-label="Help Center Menu">
<!-- Main Menu Items -->
<nav class="help-menu-section" role="menubar">
<button
v-for="menuItem in menuItems"
v-show="menuItem.visible !== false"
:key="menuItem.key"
type="button"
class="help-menu-item"
:class="{ 'more-item': menuItem.key === 'more' }"
role="menuitem"
@click="menuItem.action"
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@mouseleave="onMenuItemLeave(menuItem.key)"
>
<i :class="menuItem.icon" class="help-menu-icon" />
<span class="menu-label">{{ menuItem.label }}</span>
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
</button>
</nav>
<!-- More Submenu -->
<Teleport to="body">
<div
v-if="isSubmenuVisible"
ref="submenuRef"
class="more-submenu"
:style="submenuStyle"
@mouseenter="onSubmenuHover"
@mouseleave="onSubmenuLeave"
>
<template
v-for="submenuItem in moreMenuItem?.items"
:key="submenuItem.key"
>
<div
v-if="submenuItem.type === 'divider'"
v-show="submenuItem.visible !== false"
class="submenu-divider"
/>
<button
v-else
v-show="submenuItem.visible !== false"
type="button"
class="help-menu-item submenu-item"
role="menuitem"
@click="submenuItem.action"
>
<span class="menu-label">{{ submenuItem.label }}</span>
</button>
</template>
</div>
</Teleport>
<!-- What's New Section -->
<section v-if="showVersionUpdates" class="whats-new-section">
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
<!-- Release Items -->
<div v-if="hasReleases" role="group" aria-label="Recent releases">
<article
v-for="release in releaseStore.recentReleases"
:key="release.id || release.version"
class="help-menu-item release-menu-item"
role="button"
tabindex="0"
@click="onReleaseClick(release)"
@keydown.enter="onReleaseClick(release)"
@keydown.space.prevent="onReleaseClick(release)"
>
<i class="pi pi-refresh help-menu-icon" aria-hidden="true" />
<div class="release-content">
<span class="release-title">
{{
$t('g.releaseTitle', {
package: 'Comfy',
version: release.version
})
}}
</span>
<time class="release-date" :datetime="release.published_at">
<span class="normal-state">
{{ formatReleaseDate(release.published_at) }}
</span>
<span class="hover-state">
{{ $t('helpCenter.clickToLearnMore') }}
</span>
</time>
</div>
<Button
v-if="shouldShowUpdateButton(release)"
:label="$t('helpCenter.updateAvailable')"
size="small"
class="update-button"
@click.stop="onUpdate(release)"
/>
</article>
</div>
<!-- Loading State -->
<div
v-else-if="releaseStore.isLoading"
class="help-menu-item"
role="status"
aria-live="polite"
>
<i class="pi pi-spin pi-spinner help-menu-icon" aria-hidden="true" />
<span>{{ $t('helpCenter.loadingReleases') }}</span>
</div>
<!-- No Releases State -->
<div v-else class="help-menu-item" role="status">
<i class="pi pi-info-circle help-menu-icon" aria-hidden="true" />
<span>{{ $t('helpCenter.noRecentReleases') }}</span>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { type CSSProperties, computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { type ReleaseNote } from '@/services/releaseService'
import { useCommandStore } from '@/stores/commandStore'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
// Types
interface MenuItem {
key: string
icon?: string
label?: string
action?: () => void
visible?: boolean
type?: 'item' | 'divider'
items?: MenuItem[]
}
// Constants
const EXTERNAL_LINKS = {
DOCS: 'https://docs.comfy.org/',
DISCORD: 'https://www.comfy.org/discord',
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
DESKTOP_GUIDE: 'https://comfyorg.notion.site/',
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
} as const
const TIME_UNITS = {
MINUTE: 60 * 1000,
HOUR: 60 * 60 * 1000,
DAY: 24 * 60 * 60 * 1000,
WEEK: 7 * 24 * 60 * 60 * 1000,
MONTH: 30 * 24 * 60 * 60 * 1000,
YEAR: 365 * 24 * 60 * 60 * 1000
} as const
const SUBMENU_CONFIG = {
DELAY_MS: 100,
OFFSET_PX: 8,
Z_INDEX: 10001
} as const
// Composables
const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
// Emits
const emit = defineEmits<{
close: []
}>()
// State
const isSubmenuVisible = ref(false)
const submenuRef = ref<HTMLElement | null>(null)
const submenuStyle = ref<CSSProperties>({})
let hoverTimeout: number | null = null
// Computed
const hasReleases = computed(() => releaseStore.releases.length > 0)
const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
const menuItems = computed<MenuItem[]>(() => {
const moreItems: MenuItem[] = [
{
key: 'desktop-guide',
type: 'item',
label: t('helpCenter.desktopUserGuide'),
action: () => {
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
emit('close')
}
},
{
key: 'dev-tools',
type: 'item',
label: t('helpCenter.openDevTools'),
visible: isElectron(),
action: () => {
openDevTools()
emit('close')
}
},
{
key: 'divider-1',
type: 'divider',
visible: isElectron()
},
{
key: 'reinstall',
type: 'item',
label: t('helpCenter.reinstall'),
visible: isElectron(),
action: () => {
onReinstall()
emit('close')
}
}
]
return [
{
key: 'docs',
type: 'item',
icon: 'pi pi-book',
label: t('helpCenter.docs'),
action: () => {
openExternalLink(EXTERNAL_LINKS.DOCS)
emit('close')
}
},
{
key: 'discord',
type: 'item',
icon: 'pi pi-discord',
label: 'Discord',
action: () => {
openExternalLink(EXTERNAL_LINKS.DISCORD)
emit('close')
}
},
{
key: 'github',
type: 'item',
icon: 'pi pi-github',
label: t('helpCenter.github'),
action: () => {
openExternalLink(EXTERNAL_LINKS.GITHUB)
emit('close')
}
},
{
key: 'help',
type: 'item',
icon: 'pi pi-question-circle',
label: t('helpCenter.helpFeedback'),
action: () => {
void commandStore.execute('Comfy.Feedback')
emit('close')
}
},
{
key: 'more',
type: 'item',
icon: '',
label: t('helpCenter.more'),
action: () => {}, // No action for more item
items: moreItems
}
]
})
// Utility Functions
const openExternalLink = (url: string): void => {
window.open(url, '_blank', 'noopener,noreferrer')
}
const clearHoverTimeout = (): void => {
if (hoverTimeout) {
clearTimeout(hoverTimeout)
hoverTimeout = null
}
}
const calculateSubmenuPosition = (button: HTMLElement): CSSProperties => {
const rect = button.getBoundingClientRect()
const submenuWidth = 210 // Width defined in CSS
// Get actual submenu height if available, otherwise estimate based on visible item count
const visibleItemCount =
moreMenuItem.value?.items?.filter((item) => item.visible !== false)
.length || 0
const estimatedHeight = visibleItemCount * 48 + 16 // ~48px per item + padding
const submenuHeight = submenuRef.value?.offsetHeight || estimatedHeight
// Get viewport dimensions
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Calculate basic position (aligned with button)
let top = rect.top
let left = rect.right + SUBMENU_CONFIG.OFFSET_PX
// Check if submenu would overflow viewport on the right
if (left + submenuWidth > viewportWidth) {
// Position submenu to the left of the button instead
left = rect.left - submenuWidth - SUBMENU_CONFIG.OFFSET_PX
}
// Check if submenu would overflow viewport at the bottom
if (top + submenuHeight > viewportHeight) {
// Position submenu above the button, aligned to bottom
top = Math.max(
SUBMENU_CONFIG.OFFSET_PX, // Minimum distance from top of viewport
rect.bottom - submenuHeight
)
}
// Ensure submenu doesn't go above viewport
if (top < SUBMENU_CONFIG.OFFSET_PX) {
top = SUBMENU_CONFIG.OFFSET_PX
}
top -= 8
return {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
zIndex: SUBMENU_CONFIG.Z_INDEX
}
}
const formatReleaseDate = (dateString?: string): string => {
if (!dateString) return 'date'
const date = new Date(dateString)
const now = new Date()
const diffTime = Math.abs(now.getTime() - date.getTime())
const timeUnits = [
{ unit: TIME_UNITS.YEAR, suffix: 'y' },
{ unit: TIME_UNITS.MONTH, suffix: 'mo' },
{ unit: TIME_UNITS.WEEK, suffix: 'w' },
{ unit: TIME_UNITS.DAY, suffix: 'd' },
{ unit: TIME_UNITS.HOUR, suffix: 'h' },
{ unit: TIME_UNITS.MINUTE, suffix: 'min' }
]
for (const { unit, suffix } of timeUnits) {
const value = Math.floor(diffTime / unit)
if (value > 0) {
return `${value}${suffix} ago`
}
}
return 'now'
}
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
return (
releaseStore.shouldShowUpdateButton &&
release === releaseStore.recentReleases[0]
)
}
// Event Handlers
const onMenuItemHover = async (
key: string,
event: MouseEvent
): Promise<void> => {
if (key !== 'more' || !moreMenuItem.value?.items) return
// Don't show submenu if all items are hidden
const hasVisibleItems = moreMenuItem.value.items.some(
(item) => item.visible !== false
)
if (!hasVisibleItems) return
clearHoverTimeout()
const moreButton = event.currentTarget as HTMLElement
// Calculate initial position before showing submenu
submenuStyle.value = calculateSubmenuPosition(moreButton)
// Show submenu with correct position
isSubmenuVisible.value = true
// After submenu is rendered, refine position if needed
await nextTick()
if (submenuRef.value) {
submenuStyle.value = calculateSubmenuPosition(moreButton)
}
}
const onMenuItemLeave = (key: string): void => {
if (key !== 'more') return
hoverTimeout = window.setTimeout(() => {
isSubmenuVisible.value = false
}, SUBMENU_CONFIG.DELAY_MS)
}
const onSubmenuHover = (): void => {
clearHoverTimeout()
}
const onSubmenuLeave = (): void => {
isSubmenuVisible.value = false
}
const openDevTools = (): void => {
if (isElectron()) {
electronAPI().openDevTools()
}
}
const onReinstall = (): void => {
if (isElectron()) {
void electronAPI().reinstall()
}
}
const onReleaseClick = (release: ReleaseNote): void => {
void releaseStore.handleShowChangelog(release.version)
const versionAnchor = formatVersionAnchor(release.version)
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
openExternalLink(changelogUrl)
emit('close')
}
const onUpdate = (_: ReleaseNote): void => {
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
emit('close')
}
// Generate language-aware changelog URL
const getChangelogUrl = (): string => {
const isChineseLocale = locale.value === 'zh'
return isChineseLocale
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
}
// Lifecycle
onMounted(async () => {
if (!hasReleases.value) {
await releaseStore.fetchReleases()
}
})
</script>
<style scoped>
.help-center-menu {
width: 380px;
max-height: 500px;
overflow-y: auto;
background: var(--p-content-background);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
border: 1px solid var(--p-content-border-color);
backdrop-filter: blur(8px);
position: relative;
}
.help-menu-section {
padding: 0.5rem 0;
border-bottom: 1px solid var(--p-content-border-color);
}
.help-menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.9rem;
color: inherit;
text-align: left;
}
.help-menu-item:hover {
background-color: #007aff26;
}
.help-menu-item:focus,
.help-menu-item:focus-visible {
outline: none;
box-shadow: none;
}
.help-menu-icon {
margin-right: 0.75rem;
font-size: 1rem;
color: var(--p-text-muted-color);
width: 16px;
display: flex;
justify-content: center;
flex-shrink: 0;
}
.menu-label {
flex: 1;
}
.more-item {
justify-content: space-between;
}
.whats-new-section {
padding: 0.5rem 0;
}
.section-description {
font-size: 0.8rem;
font-weight: 600;
color: var(--p-text-muted-color);
margin: 0 0 0.5rem 0;
padding: 0 1rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.release-menu-item {
position: relative;
}
.release-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.release-title {
font-size: 0.9rem;
line-height: 1.2;
font-weight: 500;
}
.release-date {
height: 16px;
font-size: 0.75rem;
color: var(--p-text-muted-color);
}
.release-date .hover-state {
display: none;
}
.release-menu-item:hover .release-date .normal-state,
.release-menu-item:focus-within .release-date .normal-state {
display: none;
}
.release-menu-item:hover .release-date .hover-state,
.release-menu-item:focus-within .release-date .hover-state {
display: inline;
}
.update-button {
margin-left: 0.5rem;
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
flex-shrink: 0;
}
/* Submenu Styles */
.more-submenu {
width: 210px;
padding: 0.5rem 0;
background: var(--p-content-background);
border-radius: 12px;
border: 1px solid var(--p-content-border-color);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
overflow: hidden;
transition: opacity 0.15s ease-out;
}
.submenu-item {
padding: 0.75rem 1rem;
color: inherit;
font-size: 0.9rem;
font-weight: inherit;
line-height: inherit;
}
.submenu-item:hover {
background-color: #007aff26;
}
.submenu-item:focus,
.submenu-item:focus-visible {
outline: none;
box-shadow: none;
}
.submenu-divider {
height: 1px;
background: #3e3e3e;
margin: 0.5rem 0;
}
/* Scrollbar Styling */
.help-center-menu::-webkit-scrollbar {
width: 6px;
}
.help-center-menu::-webkit-scrollbar-track {
background: transparent;
}
.help-center-menu::-webkit-scrollbar-thumb {
background: var(--p-content-border-color);
border-radius: 3px;
}
.help-center-menu::-webkit-scrollbar-thumb:hover {
background: var(--p-text-muted-color);
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
.help-menu-item {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<div v-if="shouldShow" class="release-toast-popup">
<div class="release-notification-toast">
<!-- Header section with icon and text -->
<div class="toast-header">
<div class="toast-icon">
<i class="pi pi-download" />
</div>
<div class="toast-text">
<div class="toast-title">
{{ $t('releaseToast.newVersionAvailable') }}
</div>
<div class="toast-version-badge">
{{ latestRelease?.version }}
</div>
</div>
</div>
<!-- Actions section -->
<div class="toast-actions-section">
<div class="actions-row">
<div class="left-actions">
<a
class="learn-more-link"
:href="changelogUrl"
target="_blank"
rel="noopener,noreferrer"
@click="handleLearnMore"
>
{{ $t('releaseToast.whatsNew') }}
</a>
</div>
<div class="right-actions">
<button class="skip-button" @click="handleSkip">
{{ $t('releaseToast.skip') }}
</button>
<button class="cta-button" @click="handleUpdate">
{{ $t('releaseToast.update') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { formatVersionAnchor } from '@/utils/formatUtil'
const { locale } = useI18n()
const releaseStore = useReleaseStore()
// Local state for dismissed status
const isDismissed = ref(false)
// Get latest release from store
const latestRelease = computed<ReleaseNote | null>(
() => releaseStore.recentRelease
)
// Show toast when new version available and not dismissed
const shouldShow = computed(
() => releaseStore.shouldShowToast && !isDismissed.value
)
// Generate changelog URL with version anchor (language-aware)
const changelogUrl = computed(() => {
const isChineseLocale = locale.value === 'zh'
const baseUrl = isChineseLocale
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
if (latestRelease.value?.version) {
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
return `${baseUrl}#${versionAnchor}`
}
return baseUrl
})
// Auto-hide timer
let hideTimer: ReturnType<typeof setTimeout> | null = null
const startAutoHide = () => {
if (hideTimer) clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
dismissToast()
}, 8000) // 8 second auto-hide
}
const clearAutoHide = () => {
if (hideTimer) {
clearTimeout(hideTimer)
hideTimer = null
}
}
const dismissToast = () => {
isDismissed.value = true
clearAutoHide()
}
const handleSkip = () => {
if (latestRelease.value) {
void releaseStore.handleSkipRelease(latestRelease.value.version)
}
dismissToast()
}
const handleLearnMore = () => {
if (latestRelease.value) {
void releaseStore.handleShowChangelog(latestRelease.value.version)
}
// Do not dismiss; anchor will navigate in new tab but keep toast? spec maybe wants dismiss? We'll dismiss.
dismissToast()
}
const handleUpdate = () => {
window.open('https://docs.comfy.org/installation/update_comfyui', '_blank')
dismissToast()
}
// Learn more handled by anchor href
// Start auto-hide when toast becomes visible
watch(shouldShow, (isVisible) => {
if (isVisible) {
startAutoHide()
} else {
clearAutoHide()
}
})
// Initialize on mount
onMounted(async () => {
// Fetch releases if not already loaded
if (!releaseStore.releases.length) {
await releaseStore.fetchReleases()
}
})
</script>
<style scoped>
/* Toast popup - positioning handled by parent */
.release-toast-popup {
position: absolute;
bottom: 1rem;
z-index: 1000;
pointer-events: auto;
}
/* Sidebar positioning classes applied by parent - matching help center */
.release-toast-popup.sidebar-left {
left: 1rem;
}
.release-toast-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.release-toast-popup.sidebar-right {
right: 1rem;
}
/* Main toast container */
.release-notification-toast {
width: 448px;
padding: 16px 16px 8px;
background: #353535;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 12px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Header section */
.toast-header {
display: flex;
gap: 16px;
align-items: flex-start;
}
/* Icon container */
.toast-icon {
width: 42px;
height: 42px;
padding: 10px;
background: rgba(0, 122, 255, 0.2);
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.toast-icon i {
color: #007aff;
font-size: 16px;
}
/* Text content */
.toast-text {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.toast-title {
color: white;
font-size: 14px;
font-family: 'Satoshi', sans-serif;
font-weight: 500;
line-height: 18.2px;
}
.toast-version-badge {
color: #a0a1a2;
font-size: 12px;
font-family: 'Satoshi', sans-serif;
font-weight: 500;
line-height: 15.6px;
}
/* Actions section */
.toast-actions-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions-row {
padding-left: 58px; /* Align with text content */
padding-right: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.left-actions {
display: flex;
align-items: center;
}
/* Learn more link - simple text link */
.learn-more-link {
color: #60a5fa;
font-size: 12px;
font-family: 'Inter', sans-serif;
font-weight: 500;
line-height: 15.6px;
text-decoration: none;
}
.learn-more-link:hover {
text-decoration: underline;
}
.right-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Button styles */
.skip-button {
padding: 8px 16px;
background: #353535;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
border: none;
color: #aeaeb2;
font-size: 12px;
font-family: 'Inter', sans-serif;
font-weight: 500;
cursor: pointer;
}
.skip-button:hover {
background: #404040;
}
.cta-button {
padding: 8px 16px;
background: white;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
border: none;
color: black;
font-size: 12px;
font-family: 'Inter', sans-serif;
font-weight: 500;
cursor: pointer;
}
.cta-button:hover {
background: #f0f0f0;
}
</style>

View File

@@ -0,0 +1,451 @@
<template>
<div v-if="shouldShow" class="whats-new-popup-container">
<!-- Arrow pointing to help center -->
<div class="help-center-arrow">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="19"
viewBox="0 0 16 19"
fill="none"
>
<!-- Arrow fill -->
<path
d="M15.25 1.27246L15.25 17.7275L0.999023 9.5L15.25 1.27246Z"
fill="#353535"
/>
<!-- Top and bottom outlines only -->
<path
d="M15.25 1.27246L0.999023 9.5"
stroke="#4e4e4e"
stroke-width="1"
fill="none"
/>
<path
d="M0.999023 9.5L15.25 17.7275"
stroke="#4e4e4e"
stroke-width="1"
fill="none"
/>
</svg>
</div>
<div class="whats-new-popup" @click.stop>
<!-- Close Button -->
<button
class="close-button"
:aria-label="$t('g.close')"
@click="closePopup"
>
<div class="close-icon"></div>
</button>
<!-- Release Content -->
<div class="popup-content">
<div class="content-text" v-html="formattedContent"></div>
<!-- Actions Section -->
<div class="popup-actions">
<a
class="learn-more-link"
:href="changelogUrl"
target="_blank"
rel="noopener,noreferrer"
@click="closePopup"
>
{{ $t('whatsNewPopup.learnMore') }}
</a>
<!-- TODO: CTA button -->
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { marked } from 'marked'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { formatVersionAnchor } from '@/utils/formatUtil'
const { locale, t } = useI18n()
const releaseStore = useReleaseStore()
// Local state for dismissed status
const isDismissed = ref(false)
// Get latest release from store
const latestRelease = computed<ReleaseNote | null>(
() => releaseStore.recentRelease
)
// Show popup when on latest version and not dismissed
const shouldShow = computed(
() => releaseStore.shouldShowPopup && !isDismissed.value
)
// Generate changelog URL with version anchor (language-aware)
const changelogUrl = computed(() => {
const isChineseLocale = locale.value === 'zh'
const baseUrl = isChineseLocale
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
if (latestRelease.value?.version) {
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
return `${baseUrl}#${versionAnchor}`
}
return baseUrl
})
// Format release content for display using marked
const formattedContent = computed(() => {
if (!latestRelease.value?.content) {
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
}
try {
// Use marked to parse markdown to HTML
return marked(latestRelease.value.content, {
gfm: true // Enable GitHub Flavored Markdown
})
} catch (error) {
console.error('Error parsing markdown:', error)
// Fallback to plain text with line breaks
return latestRelease.value.content.replace(/\n/g, '<br>')
}
})
const show = () => {
isDismissed.value = false
}
const hide = () => {
isDismissed.value = true
}
const closePopup = async () => {
// Mark "what's new" seen when popup is closed
if (latestRelease.value) {
await releaseStore.handleWhatsNewSeen(latestRelease.value.version)
}
hide()
}
// Learn more handled by anchor href
// const handleCTA = async () => {
// window.open('https://docs.comfy.org/installation/update_comfyui', '_blank')
// await closePopup()
// }
// Initialize on mount
onMounted(async () => {
// Fetch releases if not already loaded
if (!releaseStore.releases.length) {
await releaseStore.fetchReleases()
}
})
// Expose methods for parent component
defineExpose({
show,
hide
})
</script>
<style scoped>
/* Popup container - positioning handled by parent */
.whats-new-popup-container {
position: absolute;
bottom: 1rem;
z-index: 1000;
pointer-events: auto;
}
/* Arrow pointing to help center */
.help-center-arrow {
position: absolute;
bottom: calc(
var(--sidebar-width, 4rem) + 0.25rem
); /* Position toward center of help center icon */
transform: none;
z-index: 999;
pointer-events: none;
}
/* Position arrow based on sidebar location */
.whats-new-popup-container.sidebar-left .help-center-arrow {
left: -14px; /* Overlap with popup outline */
}
.whats-new-popup-container.sidebar-left.small-sidebar .help-center-arrow {
left: -14px; /* Overlap with popup outline */
bottom: calc(2.5rem + 0.25rem); /* Adjust for small sidebar */
}
/* Sidebar positioning classes applied by parent */
.whats-new-popup-container.sidebar-left {
left: 1rem;
}
.whats-new-popup-container.sidebar-left.small-sidebar {
left: 1rem;
}
.whats-new-popup-container.sidebar-right {
right: 1rem;
}
.whats-new-popup {
background: #353535;
border-radius: 12px;
max-width: 400px;
width: 400px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
box-shadow: 0px 8px 32px rgba(0, 0, 0, 0.3);
position: relative;
}
/* Content Section */
.popup-content {
display: flex;
flex-direction: column;
gap: 24px;
overflow: hidden;
padding: 32px 32px 24px;
border-radius: 12px;
}
/* Close button */
.close-button {
position: absolute;
top: 0;
right: 0;
width: 32px;
height: 32px;
padding: 6px;
background: #7c7c7c;
border-radius: 16px;
border: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
transform: translate(30%, -30%);
transition:
background-color 0.2s ease,
transform 0.1s ease;
z-index: 1;
}
.close-button:hover {
background: #8e8e8e;
}
.close-button:active {
background: #6a6a6a;
transform: translate(30%, -30%) scale(0.95);
}
.close-icon {
width: 16px;
height: 16px;
position: relative;
opacity: 0.9;
transition: opacity 0.2s ease;
}
.close-button:hover .close-icon {
opacity: 1;
}
.close-icon::before,
.close-icon::after {
content: '';
position: absolute;
width: 12px;
height: 2px;
background: white;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(45deg);
transition: background-color 0.2s ease;
}
.close-icon::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
/* Content Section */
.popup-content {
display: flex;
flex-direction: column;
}
.content-text {
color: white;
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
}
/* Style the markdown content */
/* Title */
.content-text :deep(*) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.content-text :deep(h1) {
font-size: 16px;
font-weight: 700;
margin-bottom: 8px;
}
/* Version subtitle - targets the first p tag after h1 */
.content-text :deep(h1 + p) {
color: #c0c0c0;
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
opacity: 0.8;
}
/* Regular paragraphs - short description */
.content-text :deep(p) {
margin-bottom: 16px;
color: #e0e0e0;
}
/* List */
.content-text :deep(ul),
.content-text :deep(ol) {
margin-bottom: 16px;
padding-left: 0;
list-style: none;
}
.content-text :deep(ul:first-child),
.content-text :deep(ol:first-child) {
margin-top: 0;
}
.content-text :deep(ul:last-child),
.content-text :deep(ol:last-child) {
margin-bottom: 0;
}
/* List items */
.content-text :deep(li) {
margin-bottom: 8px;
position: relative;
padding-left: 20px;
}
.content-text :deep(li:last-child) {
margin-bottom: 0;
}
/* Custom bullet points */
.content-text :deep(li::before) {
content: '';
position: absolute;
left: 0;
top: 10px;
display: flex;
width: 8px;
height: 8px;
justify-content: center;
align-items: center;
aspect-ratio: 1/1;
border-radius: 100px;
background: #60a5fa;
}
/* List item strong text */
.content-text :deep(li strong) {
color: #fff;
font-size: 14px;
display: block;
margin-bottom: 4px;
}
.content-text :deep(li p) {
font-size: 12px;
margin-bottom: 0;
line-height: 2;
}
/* Code styling */
.content-text :deep(code) {
background-color: #2a2a2a;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 2px 6px;
color: #f8f8f2;
white-space: nowrap;
}
/* Remove top margin for first media element */
.content-text :deep(img:first-child),
.content-text :deep(video:first-child),
.content-text :deep(iframe:first-child) {
margin-top: -32px; /* Align with the top edge of the popup content */
margin-bottom: 24px;
}
/* Media elements */
.content-text :deep(img),
.content-text :deep(video),
.content-text :deep(iframe) {
width: calc(100% + 64px);
height: auto;
margin: 24px -32px;
display: block;
}
/* Actions Section */
.popup-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.learn-more-link {
color: #60a5fa;
font-size: 14px;
font-weight: 500;
line-height: 18.2px;
text-decoration: none;
}
.learn-more-link:hover {
text-decoration: underline;
}
.cta-button {
height: 40px;
padding: 0 20px;
background: white;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
border: none;
color: #121212;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.cta-button:hover {
background: #f0f0f0;
}
</style>

View File

@@ -206,7 +206,11 @@ const handleBackgroundImageUpdate = async (file: File | null) => {
return
}
backgroundImage.value = await Load3dUtils.uploadFile(file)
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d'
backgroundImage.value = await Load3dUtils.uploadFile(file, subfolder)
node.properties['Background Image'] = backgroundImage.value
}
@@ -218,7 +222,14 @@ const handleUploadTexture = async (file: File) => {
}
try {
const texturePath = await Load3dUtils.uploadFile(file)
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const texturePath = await Load3dUtils.uploadFile(file, subfolder)
await load3DSceneRef.value.load3d.applyTexture(texturePath)
node.properties['Texture'] = texturePath

View File

@@ -238,7 +238,11 @@ const handleBackgroundImageUpdate = async (file: File | null) => {
return
}
backgroundImage.value = await Load3dUtils.uploadFile(file)
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d'
backgroundImage.value = await Load3dUtils.uploadFile(file, subfolder)
node.properties['Background Image'] = backgroundImage.value
}

View File

@@ -15,7 +15,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div class="_sb_dot headdot" />
{{ nodeDef.display_name }}
</div>
<div class="_sb_preview_badge">PREVIEW</div>
<div class="_sb_preview_badge">{{ $t('g.preview') }}</div>
<!-- Node slot I/O -->
<div

View File

@@ -27,7 +27,7 @@
@hide="reFocusInput"
>
<template #header>
<h3>Add node filter condition</h3>
<h3>{{ $t('g.addNodeFilterCondition') }}</h3>
</template>
<div class="_dialog-body">
<NodeSearchFilter @add-filter="onAddFilter" />

View File

@@ -95,12 +95,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
return
}
disconnectOnReset = false
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
if (disconnectOnReset) {
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
}
disconnectOnReset = false
// Notify changeTracker - new step should be added
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()

View File

@@ -6,7 +6,8 @@
:key="tab.id"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip + getTabTooltipSuffix(tab)"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
@@ -14,6 +15,7 @@
<div class="side-tool-bar-end">
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarThemeToggleIcon />
<SidebarHelpCenterIcon />
<SidebarSettingsToggleIcon />
</div>
</nav>
@@ -36,6 +38,7 @@ import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'

View File

@@ -0,0 +1,157 @@
<template>
<div>
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
@click="toggleHelpCenter"
/>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
}"
>
<HelpCenterMenuContent @close="closeHelpCenter" />
</div>
</Teleport>
<!-- Release Notification Toast positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<ReleaseNotificationToast
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
}"
/>
</Teleport>
<!-- WhatsNew Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<WhatsNewPopup
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
}"
/>
</Teleport>
<!-- Backdrop to close popup when clicking outside -->
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
@click="closeHelpCenter"
/>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const { shouldShowRedDot } = storeToRefs(releaseStore)
const isHelpCenterVisible = ref(false)
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
const toggleHelpCenter = () => {
isHelpCenterVisible.value = !isHelpCenterVisible.value
}
const closeHelpCenter = () => {
isHelpCenterVisible.value = false
}
// Initialize release store on mount
onMounted(async () => {
// Initialize release store to fetch releases for toast and popup
await releaseStore.initialize()
})
</script>
<style scoped>
.help-center-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: transparent;
}
.help-center-popup {
position: absolute;
bottom: 1rem;
z-index: 10000;
animation: slideInUp 0.2s ease-out;
pointer-events: auto;
}
.help-center-popup.sidebar-left {
left: 1rem;
}
.help-center-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.help-center-popup.sidebar-right {
right: 1rem;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:deep(.p-badge) {
background: #ff3b30;
color: #ff3b30;
min-width: 8px;
height: 8px;
padding: 0;
border-radius: 9999px;
font-size: 0;
margin-top: 4px;
margin-right: 4px;
border: none;
outline: none;
box-shadow: none;
}
:deep(.p-badge.p-badge-dot) {
width: 8px !important;
}
</style>

View File

@@ -4,6 +4,7 @@ import PrimeVue from 'primevue/config'
import OverlayBadge from 'primevue/overlaybadge'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import SidebarIcon from './SidebarIcon.vue'
@@ -15,6 +16,14 @@ type SidebarIconProps = {
iconBadge?: string | (() => string | null)
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {}
}
})
describe('SidebarIcon', () => {
const exampleProps: SidebarIconProps = {
icon: 'pi pi-cog',
@@ -24,7 +33,7 @@ describe('SidebarIcon', () => {
const mountSidebarIcon = (props: Partial<SidebarIconProps>, options = {}) => {
return mount(SidebarIcon, {
global: {
plugins: [PrimeVue],
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip },
components: { OverlayBadge, Button }
},

View File

@@ -1,6 +1,10 @@
<template>
<Button
v-tooltip="{ value: tooltip, showDelay: 300, hideDelay: 300 }"
v-tooltip="{
value: computedTooltip,
showDelay: 300,
hideDelay: 300
}"
text
:pt="{
root: {
@@ -9,7 +13,7 @@
? 'p-button-primary side-bar-button-selected'
: 'p-button-secondary'
}`,
'aria-label': tooltip
'aria-label': computedTooltip
}
}"
@click="emit('click', $event)"
@@ -27,16 +31,20 @@
import Button from 'primevue/button'
import OverlayBadge from 'primevue/overlaybadge'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const {
icon = '',
selected = false,
tooltip = '',
tooltipSuffix = '',
iconBadge = ''
} = defineProps<{
icon?: string
selected?: boolean
tooltip?: string
tooltipSuffix?: string
iconBadge?: string | (() => string | null)
}>()
@@ -47,6 +55,7 @@ const overlayValue = computed(() =>
typeof iconBadge === 'function' ? iconBadge() ?? '' : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
</script>
<style>

View File

@@ -159,7 +159,7 @@ const toggleExpanded = () => {
const removeTask = async (task: TaskItemImpl) => {
if (task.isRunning) {
await api.interrupt()
await api.interrupt(task.promptId)
}
await queueStore.delete(task)
}

View File

@@ -8,11 +8,11 @@
{{ modelDef.file_name }}
</div>
<div v-if="modelDef.architecture_id" class="model_preview_architecture">
<span class="model_preview_prefix">Architecture: </span>
<span class="model_preview_prefix">{{ $t('g.architecture') }}: </span>
{{ modelDef.architecture_id }}
</div>
<div v-if="modelDef.author" class="model_preview_author">
<span class="model_preview_prefix">Author: </span>
<span class="model_preview_prefix">{{ $t('g.author') }}: </span>
{{ modelDef.author }}
</div>
</div>
@@ -20,15 +20,15 @@
<img :src="modelDef.image" />
</div>
<div v-if="modelDef.usage_hint" class="model_preview_usage_hint">
<span class="model_preview_prefix">Usage hint: </span>
<span class="model_preview_prefix">{{ $t('g.usageHint') }}: </span>
{{ modelDef.usage_hint }}
</div>
<div v-if="modelDef.trigger_phrase" class="model_preview_trigger_phrase">
<span class="model_preview_prefix">Trigger phrase: </span>
<span class="model_preview_prefix">{{ $t('g.triggerPhrase') }}: </span>
{{ modelDef.trigger_phrase }}
</div>
<div v-if="modelDef.description" class="model_preview_description">
<span class="model_preview_prefix">Description: </span>
<span class="model_preview_prefix">{{ $t('g.description') }}: </span>
{{ modelDef.description }}
</div>
</div>

View File

@@ -12,7 +12,7 @@
/>
<span class="ml-2 font-semibold">{{ node.display_name }}</span>
</div>
<div class="p-4 flex-grow node-help-content max-w-[600px] mx-auto">
<div class="p-4 flex-grow node-help-content w-full mx-auto">
<ProgressSpinner
v-if="isLoading"
class="m-auto"
@@ -188,7 +188,7 @@ const outputList = computed(() =>
.markdown-content :deep(li),
.fallback-content li {
@apply my-1;
@apply my-2;
}
.markdown-content :deep(*:first-child),
@@ -198,7 +198,9 @@ const outputList = computed(() =>
.markdown-content :deep(code),
.fallback-content code {
@apply text-[var(--error-text)] bg-[var(--content-bg)] rounded px-1 py-0.5;
color: var(--code-text-color);
background-color: var(--code-bg-color);
@apply rounded px-1.5 py-0.5;
}
.markdown-content :deep(table),
@@ -227,4 +229,15 @@ const outputList = computed(() =>
.fallback-content thead {
border-bottom: 1px solid var(--p-text-color);
}
.markdown-content :deep(pre),
.fallback-content pre {
@apply rounded p-4 my-4 overflow-x-auto;
background-color: var(--code-block-bg-color);
code {
@apply bg-transparent p-0;
color: var(--p-text-color);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="absolute top-0 left-0 w-auto max-w-full">
<div class="w-auto max-w-full">
<WorkflowTabs />
</div>
</template>

View File

@@ -66,50 +66,150 @@ The following diagram shows how composables fit into the application architectur
## Composable Categories
ComfyUI's composables are organized into several categories:
The following tables list ALL composables in the system as of 2025-01-30:
### Auth
Composables for authentication and user management:
- `useCurrentUser` - Provides access to the current user information
- `useFirebaseAuthActions` - Handles Firebase authentication operations
| Composable | Description |
|------------|-------------|
| `useCurrentUser` | Provides access to the current user information |
| `useFirebaseAuthActions` | Handles Firebase authentication operations |
### Bottom Panel Tabs
Composables for terminal and bottom panel functionality:
| Composable | Description |
|------------|-------------|
| `useTerminal` | Core terminal functionality |
| `useTerminalBuffer` | Manages terminal output buffer |
| `useTerminalTabs` | Handles multiple terminal tab management |
### Element
Composables for DOM and element interactions:
- `useAbsolutePosition` - Handles element positioning
- `useDomClipping` - Manages clipping of DOM elements
- `useResponsiveCollapse` - Manages responsive collapsing of elements
| Composable | Description |
|------------|-------------|
| `useAbsolutePosition` | Handles element positioning |
| `useCanvasPositionConversion` | Converts between canvas and DOM coordinates |
| `useDomClipping` | Manages clipping of DOM elements |
| `useResponsiveCollapse` | Manages responsive collapsing of elements |
### Functional
Utility composables for common patterns:
| Composable | Description |
|------------|-------------|
| `useChainCallback` | Chains multiple callbacks together |
### Manager
Composables for ComfyUI Manager integration:
| Composable | Description |
|------------|-------------|
| `useManagerStatePersistence` | Persists manager UI state |
### Node Pack
Composables for node package management:
| Composable | Description |
|------------|-------------|
| `useInstalledPacks` | Manages installed node packages |
| `useMissingNodes` | Detects and handles missing nodes |
| `useNodePacks` | Core node package functionality |
| `usePackUpdateStatus` | Tracks package update availability |
| `useWorkflowPacks` | Manages packages used in workflows |
### Node
Composables for node-specific functionality:
- `useNodeBadge` - Handles node badge display and interaction
- `useNodeImage` - Manages node image preview
- `useNodeDragAndDrop` - Handles drag and drop for nodes
- `useNodeChatHistory` - Manages chat history for nodes
| Composable | Description |
|------------|-------------|
| `useNodeAnimatedImage` | Handles animated images in nodes |
| `useNodeBadge` | Handles node badge display and interaction |
| `useNodeCanvasImagePreview` | Canvas-based image preview for nodes |
| `useNodeChatHistory` | Manages chat history for nodes |
| `useNodeDragAndDrop` | Handles drag and drop for nodes |
| `useNodeFileInput` | Manages file input widgets in nodes |
| `useNodeImage` | Manages node image preview |
| `useNodeImageUpload` | Handles image upload for nodes |
| `useNodePaste` | Manages paste operations for nodes |
| `useNodePricing` | Handles pricing display for nodes |
| `useNodeProgressText` | Displays progress text in nodes |
| `useWatchWidget` | Watches widget value changes |
### Settings
Composables for settings management:
- `useSettingSearch` - Provides search functionality for settings
- `useSettingUI` - Manages settings UI interactions
### Sidebar
| Composable | Description |
|------------|-------------|
| `useSettingSearch` | Provides search functionality for settings |
| `useSettingUI` | Manages settings UI interactions |
### Sidebar Tabs
Composables for sidebar functionality:
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
- `useQueueSidebarTab` - Manages the queue sidebar tab
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
- `useTemplateWorkflows` - Manages template workflow loading, selection, and display
| Composable | Description |
|------------|-------------|
| `useModelLibrarySidebarTab` | Manages the model library sidebar tab |
| `useNodeLibrarySidebarTab` | Manages the node library sidebar tab |
| `useQueueSidebarTab` | Manages the queue sidebar tab |
| `useWorkflowsSidebarTab` | Manages the workflows sidebar tab |
### Tree
Composables for tree structure operations:
| Composable | Description |
|------------|-------------|
| `useTreeFolderOperations` | Handles folder operations in tree views |
### Widgets
Composables for widget functionality:
- `useBooleanWidget` - Manages boolean widget interactions
- `useComboWidget` - Manages combo box widget interactions
- `useFloatWidget` - Manages float input widget interactions
- `useImagePreviewWidget` - Manages image preview widget
| Composable | Description |
|------------|-------------|
| `useBooleanWidget` | Manages boolean widget interactions |
| `useChatHistoryWidget` | Handles chat history widget |
| `useComboWidget` | Manages combo box widget interactions |
| `useFloatWidget` | Manages float input widget interactions |
| `useImagePreviewWidget` | Manages image preview widget |
| `useImageUploadWidget` | Handles image upload widget |
| `useIntWidget` | Manages integer input widget |
| `useMarkdownWidget` | Handles markdown display widget |
| `useProgressTextWidget` | Manages progress text widget |
| `useRemoteWidget` | Handles remote widget connections |
| `useStringWidget` | Manages string input widget |
### Root-level Composables
General-purpose composables:
| Composable | Description |
|------------|-------------|
| `useBrowserTabTitle` | Manages browser tab title updates |
| `useCachedRequest` | Provides request caching functionality |
| `useCanvasDrop` | Handles drop operations on canvas |
| `useCivitaiModel` | Integrates with Civitai model API |
| `useContextMenuTranslation` | Handles context menu translations |
| `useCopy` | Provides copy functionality |
| `useCopyToClipboard` | Manages clipboard operations |
| `useCoreCommands` | Provides core command functionality |
| `useDownload` | Handles file download operations |
| `useErrorHandling` | Centralized error handling |
| `useGlobalLitegraph` | Access to global LiteGraph instance |
| `useLitegraphSettings` | Manages LiteGraph configuration |
| `useManagerQueue` | Handles manager queue operations |
| `usePaste` | Provides paste functionality |
| `usePragmaticDragAndDrop` | Integrates Atlassian's drag-and-drop library |
| `useProgressFavicon` | Updates favicon with progress indicator |
| `useRefreshableSelection` | Manages refreshable selections |
| `useRegistrySearch` | Searches the ComfyUI registry |
| `useServerLogs` | Manages server log display |
| `useTemplateWorkflows` | Manages template workflow loading, selection, and display |
| `useTreeExpansion` | Handles tree node expansion state |
| `useValueTransform` | Transforms values between formats |
| `useWorkflowAutoSave` | Handles automatic workflow saving |
| `useWorkflowPersistence` | Manages workflow persistence |
| `useWorkflowValidation` | Validates workflow integrity |
## Usage Guidelines

View File

@@ -151,7 +151,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const renderingSpeed = String(renderingSpeedWidget.value)
if (renderingSpeed.toLowerCase().includes('quality')) {
basePrice = 0.08
basePrice = 0.09
} else if (renderingSpeed.toLowerCase().includes('balanced')) {
basePrice = 0.06
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
@@ -322,15 +322,15 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const effectScene = String(effectSceneWidget.value)
if (
effectScene.includes('fuzzyfuzzy') ||
effectScene.includes('squish') ||
effectScene.includes('expansion')
effectScene.includes('squish')
) {
return '$0.28/Run'
} else if (
effectScene.includes('dizzydizzy') ||
effectScene.includes('bloombloom')
) {
} else if (effectScene.includes('dizzydizzy')) {
return '$0.49/Run'
} else if (effectScene.includes('bloombloom')) {
return '$0.49/Run'
} else if (effectScene.includes('expansion')) {
return '$0.28/Run'
}
return '$0.28/Run'
@@ -448,12 +448,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
} else if (model.includes('ray-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$6.37/Run'
if (resolution.includes('1080p')) return '$2.30/Run'
if (resolution.includes('1080p')) return '$1.59/Run'
if (resolution.includes('720p')) return '$0.71/Run'
if (resolution.includes('540p')) return '$0.40/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$11.47/Run'
if (resolution.includes('1080p')) return '$4.14/Run'
if (resolution.includes('1080p')) return '$2.87/Run'
if (resolution.includes('720p')) return '$1.28/Run'
if (resolution.includes('540p')) return '$0.72/Run'
}
@@ -499,12 +499,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
} else if (model.includes('ray-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$6.37/Run'
if (resolution.includes('1080p')) return '$2.30/Run'
if (resolution.includes('1080p')) return '$1.59/Run'
if (resolution.includes('720p')) return '$0.71/Run'
if (resolution.includes('540p')) return '$0.40/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$11.47/Run'
if (resolution.includes('1080p')) return '$4.14/Run'
if (resolution.includes('1080p')) return '$2.87/Run'
if (resolution.includes('720p')) return '$1.28/Run'
if (resolution.includes('540p')) return '$0.72/Run'
}

View File

@@ -26,7 +26,7 @@ const CORE_NODES_PACK_NAME = 'comfy-core'
export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
const nodeDefStore = useNodeDefStore()
const systemStatsStore = useSystemStatsStore()
const { search } = useComfyRegistryStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const workflowPacks = ref<WorkflowPack[]>([])
@@ -70,18 +70,19 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
}
}
// Search the registry for non-core nodes
const searchResult = await search.call({
comfy_node_search: nodeName,
limit: 1
})
if (searchResult?.nodes?.length) {
const pack = searchResult.nodes[0]
// Query the registry to find which pack provides this node
const pack = await inferPackFromNodeName.call(nodeName)
if (pack) {
return {
id: pack.id,
version: pack.latest_version?.version ?? SelectedVersion.NIGHTLY
}
}
// No pack found - this node doesn't exist in the registry or couldn't be
// extracted from the parent node pack successfully
return undefined
}
/**

View File

@@ -53,6 +53,11 @@ export function useSettingSearch() {
const queryLower = query.toLocaleLowerCase()
const allSettings = Object.values(settingStore.settingsById)
const filteredSettings = allSettings.filter((setting) => {
// Filter out hidden and deprecated settings, just like in normal settings tree
if (setting.type === 'hidden' || setting.deprecated) {
return false
}
const idLower = setting.id.toLowerCase()
const nameLower = setting.name.toLowerCase()
const translatedName = st(

View File

@@ -1,5 +1,4 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
@@ -7,13 +6,11 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
import { isElectron } from '@/utils/envUtil'
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
return {
id: 'model-library',
icon: 'pi pi-box',
title: t('sideToolbar.modelLibrary'),
tooltip: t('sideToolbar.modelLibrary'),
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
component: markRaw(ModelLibrarySidebarTab),
type: 'vue',
iconBadge: () => {

View File

@@ -1,16 +1,14 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
return {
id: 'node-library',
icon: 'pi pi-book',
title: t('sideToolbar.nodeLibrary'),
tooltip: t('sideToolbar.nodeLibrary'),
title: 'sideToolbar.nodeLibrary',
tooltip: 'sideToolbar.nodeLibrary',
component: markRaw(NodeLibrarySidebarTab),
type: 'vue'
}

View File

@@ -1,12 +1,10 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueSidebarTab from '@/components/sidebar/tabs/QueueSidebarTab.vue'
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useQueueSidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
return {
id: 'queue',
@@ -15,8 +13,8 @@ export const useQueueSidebarTab = (): SidebarTabExtension => {
const value = queuePendingTaskCountStore.count.toString()
return value === '0' ? null : value
},
title: t('sideToolbar.queue'),
tooltip: t('sideToolbar.queue'),
title: 'sideToolbar.queue',
tooltip: 'sideToolbar.queue',
component: markRaw(QueueSidebarTab),
type: 'vue'
}

View File

@@ -1,5 +1,4 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import { useSettingStore } from '@/stores/settingStore'
@@ -7,10 +6,8 @@ import { useWorkflowStore } from '@/stores/workflowStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
return {
id: 'workflows',
icon: 'pi pi-folder-open',
@@ -23,8 +20,8 @@ export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
const value = workflowStore.openWorkflows.length.toString()
return value === '0' ? null : value
},
title: t('sideToolbar.workflows'),
tooltip: t('sideToolbar.workflows'),
title: 'sideToolbar.workflows',
tooltip: 'sideToolbar.workflows',
component: markRaw(WorkflowsSidebarTab),
type: 'vue'
}

View File

@@ -1,6 +1,7 @@
import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
import { t } from '@/i18n'
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
@@ -36,11 +37,34 @@ export const useBrowserTabTitle = () => {
: DEFAULT_TITLE
})
const nodeExecutionTitle = computed(() =>
executionStore.executingNode && executionStore.executingNodeProgress
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
: ''
)
const nodeExecutionTitle = computed(() => {
// Check if any nodes are in progress
const nodeProgressEntries = Object.entries(
executionStore.nodeProgressStates
)
const runningNodes = nodeProgressEntries.filter(
([_, state]) => state.state === 'running'
)
if (runningNodes.length === 0) {
return ''
}
// If multiple nodes are running
if (runningNodes.length > 1) {
return `${executionText.value}[${runningNodes.length} ${t('g.nodesRunning', 'nodes running')}]`
}
// If only one node is running
const [nodeId, state] = runningNodes[0]
const progress = Math.round((state.value / state.max) * 100)
const nodeType =
executionStore.activePrompt?.workflow?.changeTracker?.activeState?.nodes.find(
(n) => String(n.id) === nodeId
)?.type || 'Node'
return `${executionText.value}[${progress}%] ${nodeType}`
})
const workflowTitle = computed(
() =>

View File

@@ -19,7 +19,8 @@ import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useTitleEditorStore } from '@/stores/graphStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
@@ -38,6 +39,8 @@ export function useCoreCommands(): ComfyCommand[] {
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthActions = useFirebaseAuthActions()
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
const getSelectedNodes = (): LGraphNode[] => {
@@ -202,7 +205,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-stop',
label: 'Interrupt',
function: async () => {
await api.interrupt()
await api.interrupt(executionStore.activePromptId)
toastStore.add({
severity: 'info',
summary: t('g.interrupted'),
@@ -730,6 +733,30 @@ export function useCoreCommands(): ComfyCommand[] {
if (!(node instanceof LGraphNode)) return
await addFluxKontextGroupNode(node)
}
},
{
id: 'Comfy.Graph.ConvertToSubgraph',
icon: 'pi pi-sitemap',
label: 'Convert Selection to Subgraph',
versionAdded: '1.20.1',
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
const res = graph.convertToSubgraph(canvas.selectedItems)
if (!res) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.cannotCreateSubgraph'),
detail: t('toastMessages.failedToConvertToSubgraph'),
life: 3000
})
return
}
const { node } = res
canvas.select(node)
}
}
]

View File

@@ -9,6 +9,7 @@ export function useErrorHandling() {
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
console.error(error)
}
const wrapWithErrorHandling =

View File

@@ -10,14 +10,19 @@ import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
function onFloatValueChange(this: INumericWidget, v: number) {
this.value = this.options.round
? _.clamp(
Math.round((v + Number.EPSILON) / this.options.round) *
this.options.round,
this.options.min ?? -Infinity,
this.options.max ?? Infinity
)
: v
const round = this.options.round
if (round) {
const precision =
this.options.precision ?? Math.max(0, -Math.floor(Math.log10(round)))
const rounded = Math.round(v / round) * round
this.value = _.clamp(
Number(rounded.toFixed(precision)),
this.options.min ?? -Infinity,
this.options.max ?? Infinity
)
} else {
this.value = v
}
}
export const _for_testing = {
@@ -62,7 +67,7 @@ export const useFloatWidget = () => {
max: inputSpec.max ?? 2048,
round:
enableRounding && precision && !inputSpec.round
? (1_000_000 * Math.pow(0.1, precision)) / 1_000_000
? Math.pow(10, -precision)
: (inputSpec.round as number),
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10.0,

View File

@@ -23,6 +23,9 @@ export const useTextPreviewWidget = (
name: inputSpec.name,
component: TextPreviewWidget,
inputSpec,
componentProps: {
nodeId: node.id
},
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {

View File

@@ -0,0 +1,3 @@
{
"supports_preview_metadata": true
}

View File

@@ -173,5 +173,13 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'f'
},
commandId: 'Workspace.ToggleFocusMode'
},
{
combo: {
key: 'e',
ctrl: true,
shift: true
},
commandId: 'Comfy.Graph.ConvertToSubgraph'
}
]

View File

@@ -290,6 +290,7 @@ export const CORE_SETTINGS: SettingParams[] = [
options: [
{ value: 'en', text: 'English' },
{ value: 'zh', text: '中文' },
{ value: 'zh-TW', text: '繁體中文' },
{ value: 'ru', text: 'Русский' },
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
@@ -330,6 +331,14 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.20.3'
},
{
id: 'Comfy.Notification.ShowVersionUpdates',
category: ['Comfy', 'Notification Preferences'],
name: 'Show version updates',
tooltip: 'Show updates for new models, and major new features.',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.ConfirmClear',
category: ['Comfy', 'Workflow', 'ConfirmClear'],
@@ -431,6 +440,8 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Use new menu',
type: 'combo',
options: ['Disabled', 'Top', 'Bottom'],
tooltip:
'Menu bar position. On mobile devices, the menu is always shown at the top.',
migrateDeprecatedValue: (value: string) => {
// Floating is now supported by dragging the docked actionbar off.
if (value === 'Floating') {
@@ -747,6 +758,13 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
versionAdded: '1.8.7'
},
{
id: 'Comfy.InstalledVersion',
name: 'The frontend version that was running when the user first installed ComfyUI',
type: 'hidden',
defaultValue: null,
versionAdded: '1.24.0'
},
{
id: 'LiteGraph.ContextMenu.Scaling',
name: 'Scale node combo widget menus (lists) when zoomed in',
@@ -847,5 +865,24 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: false,
versionAdded: '1.19.1'
},
// Release data stored in settings
{
id: 'Comfy.Release.Version',
name: 'Last seen release version',
type: 'hidden',
defaultValue: ''
},
{
id: 'Comfy.Release.Status',
name: 'Release status',
type: 'hidden',
defaultValue: 'skipped'
},
{
id: 'Comfy.Release.Timestamp',
name: 'Release seen timestamp',
type: 'hidden',
defaultValue: 0
}
]

View File

@@ -25,20 +25,85 @@ ComfyUI's extension system follows these key principles:
## Core Extensions List
The core extensions include:
The following table lists ALL core extensions in the system as of 2025-01-30:
| Extension | Description |
|-----------|-------------|
| clipspace.ts | Implements the Clipspace feature for temporary image storage |
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities |
| groupNode.ts | Implements the group node functionality to organize workflows |
| load3d.ts | Supports 3D model loading and visualization |
| maskeditor.ts | Implements the mask editor for image masking operations |
| noteNode.ts | Adds note nodes for documentation within workflows |
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections |
| uploadImage.ts | Handles image upload functionality |
| webcamCapture.ts | Provides webcam capture capabilities |
| widgetInputs.ts | Implements various widget input types |
### Main Extensions
| Extension | Description | Category |
|-----------|-------------|----------|
| clipspace.ts | Implements the Clipspace feature for temporary image storage | Image |
| contextMenuFilter.ts | Provides context menu filtering capabilities | UI |
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities | Prompts |
| editAttention.ts | Implements attention editing functionality | Text |
| electronAdapter.ts | Adapts functionality for Electron environment | Platform |
| groupNode.ts | Implements the group node functionality to organize workflows | Graph |
| groupNodeManage.ts | Provides group node management operations | Graph |
| groupOptions.ts | Handles group node configuration options | Graph |
| index.ts | Main extension registration and coordination | Core |
| load3d.ts | Supports 3D model loading and visualization | 3D |
| maskEditorOld.ts | Legacy mask editor implementation | Image |
| maskeditor.ts | Implements the mask editor for image masking operations | Image |
| nodeTemplates.ts | Provides node template functionality | Templates |
| noteNode.ts | Adds note nodes for documentation within workflows | Graph |
| previewAny.ts | Universal preview functionality for various data types | Preview |
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections | Graph |
| saveImageExtraOutput.ts | Handles additional image output saving | Image |
| saveMesh.ts | Implements 3D mesh saving functionality | 3D |
| simpleTouchSupport.ts | Provides basic touch interaction support | Input |
| slotDefaults.ts | Manages default values for node slots | Nodes |
| uploadAudio.ts | Handles audio file upload functionality | Audio |
| uploadImage.ts | Handles image upload functionality | Image |
| webcamCapture.ts | Provides webcam capture capabilities | Media |
| widgetInputs.ts | Implements various widget input types | Widgets |
### load3d Subdirectory
Located in `extensions/core/load3d/`:
| File | Description | Type |
|------|-------------|------|
| AnimationManager.ts | Manages 3D animations | Manager |
| CameraManager.ts | Handles camera controls and positioning | Manager |
| ControlsManager.ts | Manages 3D scene controls | Manager |
| EventManager.ts | Handles 3D scene events | Manager |
| interfaces.ts | TypeScript interfaces for 3D functionality | Types |
| LightingManager.ts | Manages scene lighting | Manager |
| Load3DConfiguration.ts | Configuration for 3D loading | Config |
| Load3d.ts | Core 3D loading functionality | Core |
| Load3dAnimation.ts | Animation-specific 3D operations | Animation |
| Load3dUtils.ts | Utility functions for 3D operations | Utils |
| LoaderManager.ts | Manages various 3D file loaders | Manager |
| ModelExporter.ts | Exports 3D models to different formats | Export |
| ModelManager.ts | Manages 3D model lifecycle | Manager |
| NodeStorage.ts | Handles storage for 3D nodes | Storage |
| PreviewManager.ts | Manages 3D model previews | Manager |
| RecordingManager.ts | Handles 3D scene recording | Manager |
| SceneManager.ts | Core 3D scene management | Manager |
| ViewHelperManager.ts | Manages 3D view helpers and gizmos | Manager |
### Conditional Lines Subdirectory
Located in `extensions/core/load3d/conditional-lines/`:
| File | Description |
|------|-------------|
| ColoredShadowMaterial.js | Material for colored shadow rendering |
| ConditionalEdgesGeometry.js | Geometry for conditional edge rendering |
| ConditionalEdgesShader.js | Shader for conditional edges |
| OutsideEdgesGeometry.js | Geometry for outside edge detection |
### Lines2 Subdirectory
Located in `extensions/core/load3d/conditional-lines/Lines2/`:
| File | Description |
|------|-------------|
| ConditionalLineMaterial.js | Material for conditional line rendering |
| ConditionalLineSegmentsGeometry.js | Geometry for conditional line segments |
### ThreeJS Override Subdirectory
Located in `extensions/core/load3d/threejsOverride/`:
| File | Description |
|------|-------------|
| OverrideMTLLoader.js | Custom MTL loader with enhanced functionality |
## Extension Development

View File

@@ -1,5 +1,11 @@
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
import {
type ExecutableLGraphNode,
type ExecutionId,
LGraphNode,
LiteGraph,
SubgraphNode
} from '@comfyorg/litegraph'
import { type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
import { t } from '@/i18n'
import {
@@ -9,10 +15,13 @@ import {
} from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { ComfyExtension } from '@/types/comfy'
import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO'
import { GROUP } from '@/utils/executableGroupNodeDto'
import { deserialiseAndCreate, serialise } from '@/utils/vintageClipboard'
import { api } from '../../scripts/api'
@@ -26,8 +35,6 @@ type GroupNodeWorkflowData = {
nodes: ComfyNode[]
}
const GROUP = Symbol()
// v1 Prefix + Separator: workflow/
// v2 Prefix + Separator: workflow> (ComfyUI_frontend v1.2.63)
const PREFIX = 'workflow'
@@ -813,6 +820,7 @@ export class GroupNodeHandler {
innerNodeIndex++
) {
const innerNode = this.innerNodes[innerNodeIndex]
innerNode.graph ??= this.node.graph
for (const w of innerNode.widgets ?? []) {
if (w.type === 'converted-widget') {
@@ -899,7 +907,20 @@ export class GroupNodeHandler {
return link
}
this.node.getInnerNodes = () => {
/** @internal Used to flatten the subgraph before execution. Recursive; call with no args. */
this.node.getInnerNodes = (
computedNodeDtos: Map<ExecutionId, ExecutableLGraphNode>,
/** The path of subgraph node IDs. */
subgraphNodePath: readonly NodeId[] = [],
/** The list of nodes to add to. */
nodes: ExecutableLGraphNode[] = [],
/** The set of visited nodes. */
visited = new Set<LGraphNode>()
): ExecutableLGraphNode[] => {
if (visited.has(this.node))
throw new Error('RecursionError: while flattening subgraph')
visited.add(this.node)
if (!this.innerNodes) {
// @ts-expect-error fixme ts strict error
this.node.setInnerNodes(
@@ -910,6 +931,8 @@ export class GroupNodeHandler {
innerNode.configure(n)
// @ts-expect-error fixme ts strict error
innerNode.id = `${this.node.id}:${i}`
// @ts-expect-error fixme ts strict error
innerNode.graph = this.node.graph
return innerNode
})
)
@@ -917,7 +940,31 @@ export class GroupNodeHandler {
this.updateInnerWidgets()
return this.innerNodes
const subgraphInstanceIdPath = [...subgraphNodePath, this.node.id]
// Assertion: Deprecated, does not matter.
const subgraphNode = (this.node.graph?.getNodeById(
subgraphNodePath.at(-1)
) ?? undefined) as SubgraphNode | undefined
for (const node of this.innerNodes) {
node.graph ??= this.node.graph
// Create minimal DTOs rather than cloning the node
const currentId = String(node.id)
node.id = currentId.split(':').at(-1)
const aVeryRealNode = new ExecutableGroupNodeChildDTO(
node,
subgraphInstanceIdPath,
computedNodeDtos,
subgraphNode
)
node.id = currentId
aVeryRealNode.groupNodeHandler = this
nodes.push(aVeryRealNode)
}
return nodes
}
// @ts-expect-error fixme ts strict error
@@ -1178,9 +1225,10 @@ export class GroupNodeHandler {
node.onDrawForeground = function (ctx) {
// @ts-expect-error fixme ts strict error
onDrawForeground?.apply?.(this, arguments)
const progressState = useExecutionStore().nodeProgressStates[this.id]
if (
// @ts-expect-error fixme ts strict error
+app.runningNodeId === this.id &&
progressState &&
progressState.state === 'running' &&
this.runningInternalNodeId !== null
) {
// @ts-expect-error fixme ts strict error
@@ -1294,6 +1342,7 @@ export class GroupNodeHandler {
this.node.onRemoved = function () {
// @ts-expect-error fixme ts strict error
onRemoved?.apply(this, arguments)
// api.removeEventListener('progress_state', progress_state)
api.removeEventListener('executing', executing)
api.removeEventListener('executed', executed)
}
@@ -1503,6 +1552,9 @@ export class GroupNodeHandler {
this.linkOutputs(node, i)
app.graph.remove(node)
// Set internal ID to what is expected after workflow is reloaded
node.id = `${this.node.id}:${i}`
}
this.linkInputs()
@@ -1583,57 +1635,6 @@ export class GroupNodeHandler {
}
}
function addConvertToGroupOptions() {
// @ts-expect-error fixme ts strict error
function addConvertOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {})
const disabled =
selected.length < 2 ||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
options.splice(index, null, {
content: `Convert to Group Node`,
disabled,
callback: convertSelectedNodesToGroupNode
})
}
// @ts-expect-error fixme ts strict error
function addManageOption(options, index) {
const groups = app.graph.extra?.groupNodes
const disabled = !groups || !Object.keys(groups).length
options.splice(index, null, {
content: `Manage Group Nodes`,
disabled,
callback: () => manageGroupNodes()
})
}
// Add to canvas
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
// @ts-expect-error fixme ts strict error
const options = getCanvasMenuOptions.apply(this, arguments)
const index = options.findIndex((o) => o?.content === 'Add Group')
const insertAt = index === -1 ? options.length - 1 : index + 2
addConvertOption(options, insertAt)
addManageOption(options, insertAt + 1)
return options
}
// Add to nodes
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
// @ts-expect-error fixme ts strict error
const options = getNodeMenuOptions.apply(this, arguments)
if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === 'Properties')
const insertAt = index === -1 ? options.length - 1 : index
addConvertOption(options, insertAt)
}
return options
}
}
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
for (const node of nodes) {
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
@@ -1659,8 +1660,14 @@ async function convertSelectedNodesToGroupNode() {
if (nodes.length === 1) {
throw new Error('Please select multiple nodes to convert to group node')
}
if (nodes.some((n) => GroupNodeHandler.isGroupNode(n))) {
throw new Error('Selected nodes contain a group node')
for (const node of nodes) {
if (node instanceof SubgraphNode) {
throw new Error('Selected nodes contain a subgraph node')
}
if (GroupNodeHandler.isGroupNode(node)) {
throw new Error('Selected nodes contain a group node')
}
}
return await GroupNodeHandler.fromNodes(nodes)
}
@@ -1723,9 +1730,6 @@ const ext: ComfyExtension = {
}
}
],
setup() {
addConvertToGroupOptions()
},
async beforeConfigureGraph(
graphData: ComfyWorkflowJSON,
missingNodeTypes: string[]

View File

@@ -1,7 +1,4 @@
import type {
IComboWidget,
IStringWidget
} from '@comfyorg/litegraph/dist/types/widgets'
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
@@ -17,6 +14,80 @@ import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { useToastStore } from '@/stores/toastStore'
async function handleModelUpload(files: FileList, node: any) {
if (!files?.length) return
const modelWidget = node.widgets?.find(
(w: any) => w.name === 'model_file'
) as IStringWidget
node.properties['Texture'] = undefined
try {
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadPath = await Load3dUtils.uploadFile(files[0], subfolder)
if (!uploadPath) {
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
return
}
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath),
'input'
)
)
await useLoad3dService().getLoad3d(node)?.loadModel(modelUrl)
if (uploadPath && modelWidget) {
if (!modelWidget.options?.values?.includes(uploadPath)) {
modelWidget.options?.values?.push(uploadPath)
}
modelWidget.value = uploadPath
}
} catch (error) {
console.error('Model upload failed:', error)
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
}
}
async function handleResourcesUpload(files: FileList, node: any) {
if (!files?.length) return
try {
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
await Load3dUtils.uploadMultipleFiles(files, subfolder)
} catch (error) {
console.error('Extra resources upload failed:', error)
useToastStore().addAlert(t('toastMessages.extraResourcesUploadFailed'))
}
}
function createFileInput(
accept: string,
multiple: boolean = false
): HTMLInputElement {
const input = document.createElement('input')
input.type = 'file'
input.accept = accept
input.multiple = multiple
input.style.display = 'none'
return input
}
useExtensionService().registerExtension({
name: 'Comfy.Load3D',
settings: [
@@ -110,49 +181,34 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.gltf,.glb,.obj,.fbx,.stl'
fileInput.style.display = 'none'
const fileInput = createFileInput('.gltf,.glb,.obj,.fbx,.stl', false)
node.properties['Resource Folder'] = ''
fileInput.onchange = async () => {
if (fileInput.files?.length) {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model_file'
) as IComboWidget & { options: { values: string[] } }
node.properties['Texture'] = undefined
const uploadPath = await Load3dUtils.uploadFile(
fileInput.files[0]
).catch((error) => {
console.error('File upload failed:', error)
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
})
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath),
'input'
)
)
await useLoad3dService().getLoad3d(node)?.loadModel(modelUrl)
if (uploadPath && modelWidget) {
if (!modelWidget.options?.values?.includes(uploadPath)) {
modelWidget.options?.values?.push(uploadPath)
}
modelWidget.value = uploadPath
}
}
await handleModelUpload(fileInput.files!, node)
}
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
}
)
node.addWidget('button', 'clear', 'clear', () => {
useLoad3dService().getLoad3d(node)?.clearModel()
@@ -264,46 +320,34 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D_ANIMATION(node) {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.gltf,.glb,.fbx'
fileInput.style.display = 'none'
const fileInput = createFileInput('.gltf,.glb,.fbx', false)
node.properties['Resource Folder'] = ''
fileInput.onchange = async () => {
if (fileInput.files?.length) {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model_file'
) as IStringWidget
const uploadPath = await Load3dUtils.uploadFile(
fileInput.files[0]
).catch((error) => {
console.error('File upload failed:', error)
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
})
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath),
'input'
)
)
await useLoad3dService().getLoad3d(node)?.loadModel(modelUrl)
if (uploadPath && modelWidget) {
if (!modelWidget.options?.values?.includes(uploadPath)) {
modelWidget.options?.values?.push(uploadPath)
}
modelWidget.value = uploadPath
}
}
await handleModelUpload(fileInput.files!, node)
}
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
}
)
node.addWidget('button', 'clear', 'clear', () => {
useLoad3dService().getLoad3d(node)?.clearModel()

View File

@@ -128,6 +128,9 @@ class Load3DConfiguration {
if (!value) return
const filename = value as string
this.setResourceFolder(filename)
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(filename),
@@ -173,6 +176,21 @@ class Load3DConfiguration {
}
}
}
private setResourceFolder(filename: string): void {
const pathParts = filename.split('/').filter((part) => part.trim())
if (pathParts.length <= 2) {
return
}
const subfolderParts = pathParts.slice(1, -1)
const subfolder = subfolderParts.join('/')
if (subfolder) {
this.load3d.node.properties['Resource Folder'] = subfolder
}
}
}
export default Load3DConfiguration

View File

@@ -118,11 +118,7 @@ class Load3d {
options
)
this.loaderManager = new LoaderManager(
this.modelManager,
this.eventManager,
options
)
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
this.recordingManager = new RecordingManager(
this.sceneManager.scene,

View File

@@ -34,13 +34,14 @@ class Load3dUtils {
return await resp.json()
}
static async uploadFile(file: File) {
static async uploadFile(file: File, subfolder: string) {
let uploadPath
try {
const body = new FormData()
body.append('image', file)
body.append('subfolder', '3d')
body.append('subfolder', subfolder)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
@@ -96,6 +97,14 @@ class Load3dUtils {
return `/view?${params}`
}
static async uploadMultipleFiles(files: FileList, subfolder: string = '3d') {
const uploadPromises = Array.from(files).map((file) =>
this.uploadFile(file, subfolder)
)
await Promise.all(uploadPromises)
}
}
export default Load3dUtils

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