Compare commits

...

135 Commits

Author SHA1 Message Date
Jin Yi
114790fdca [fix] TypeScript errors in manager/compatibility branch (#4625) 2025-07-31 19:10:56 +09:00
Jin Yi
b9c1962eea [feat] Add import failure detection and error handling for package manager (#4600) 2025-07-31 19:10:56 +09:00
Jin Yi
b67ace5195 [refactor] Simplify conflict acknowledgment system and enhance UX (#4599) 2025-07-31 19:09:40 +09:00
Jin Yi
38e2fa8399 [refactor] Simplify conflict detection types and improve code maintainability (#4589) 2025-07-31 19:09:40 +09:00
Jin Yi
956bc705d7 [feat] Show conflicting status for installed packages with compatibility issues (#4579)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 19:09:40 +09:00
Jin Yi
ba8e63d515 [debug] Conflict detection debugging (#4577) 2025-07-31 19:09:40 +09:00
Jin Yi
4e673c9d06 [Design] Update InfoPanel styling and layout (#4553) 2025-07-31 19:09:40 +09:00
Jin Yi
ddb9577d4c [feat] Add bulk import failure info API and improve conflict detection (#4550)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 19:09:40 +09:00
Jin Yi
21dbb86073 [Design] Update PackVersionSelectorPopover styling (#4554) 2025-07-31 19:09:40 +09:00
Jin Yi
83119e1486 [fix] Simplify PackEnableToggle state management (#4551) 2025-07-31 19:09:40 +09:00
Jin Yi
aff42a9e47 [Manager] Optimize conflict detection with bulk API and version support (#4538) 2025-07-31 19:09:40 +09:00
Jin Yi
5f9ed0289c [Manager] Fix version selector conflict detection and UI improvements (#4539) 2025-07-31 19:09:40 +09:00
Jin Yi
af9098e436 [Manager] Fix banned status logic to only check Registry status (#4535) 2025-07-31 19:09:40 +09:00
Jin Yi
12c78190b0 [Manager] Fix toggle modal dismiss bug (#4534) 2025-07-31 19:09:39 +09:00
Jin Yi
c33fc151d8 chore: delete unnecessary description 2025-07-31 19:09:39 +09:00
comfy-jinyi
e581925903 [Manager] Fix package version matching in conflict detection (#4530)
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 19:09:39 +09:00
Jin Yi
9a8bd34170 Manager Conflict Nofitication (#4443)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 19:09:39 +09:00
Jin Yi
56d532a464 [Manager] Compatibility Detection Logic (#4348)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 19:08:29 +09:00
github-actions
9cd1e1a4d5 Update locales [skip ci] 2025-07-31 19:07:49 +09:00
github-actions
9dd6d8f16a Update locales [skip ci] 2025-07-31 19:06:50 +09:00
github-actions
ae562bb071 Update locales [skip ci] 2025-07-31 19:05:28 +09:00
github-actions
0e26c82fc3 Update locales [skip ci] 2025-07-31 19:05:28 +09:00
Jin Yi
9f3944aed5 [test] Update PackVersionBadge test to use role selector instead of Button component 2025-07-31 19:04:32 +09:00
github-actions
e60de60927 Update locales [skip ci] 2025-07-31 19:04:32 +09:00
bymyself
b9a69c72e3 fix rebase error 2025-07-31 19:04:00 +09:00
Jin Yi
261413a226 [Manager] “Restarting” state after clicking restart button (#4269) 2025-07-31 19:04:00 +09:00
Jin Yi
75607071c9 [Manager] Add update all button functionality
- Add PackUpdateButton component for bulk updates
- Add useUpdateAvailableNodes composable to track available updates
- Integrate update all button in RegistrySearchBar
- Add localization strings for update functionality
- Add comprehensive tests for update functionality
- Add loading state to PackActionButton
2025-07-31 19:04:00 +09:00
bymyself
0f4d424aa9 [tests] Update useServerLogs test after log subscription change
The test was expecting subscribeLogs(false) to be called, but this was commented out in commit 33d64475 to fix logs stopping after the first of multiple queue tasks. Updated test to reflect this temporary change.
2025-07-31 19:04:00 +09:00
bymyself
36f5767936 remove the temporary check for legacy custom node version of manager 2025-07-31 19:04:00 +09:00
bymyself
b415d8a5d6 fix: logs stops listening after 1st of multiple queue tasks 2025-07-31 19:04:00 +09:00
bymyself
5845027063 [tests] Update useServerLogs test to handle task-started events
Update test to simulate cm-task-started events before logs events to match the actual behavior of the composable.
2025-07-31 19:04:00 +09:00
Christian Byrne
9eac62407a [Manager] Fix: failed tasks logs not correctly partitioned in UI (#4242) 2025-07-31 19:04:00 +09:00
bymyself
23f4e9a2d4 fix failed task tab state binding 2025-07-31 19:04:00 +09:00
Christian Byrne
1f9062e4e4 [Manager] Filter task queue and history by client id (#4241) 2025-07-31 19:02:21 +09:00
github-actions
f53ee37959 Update locales [skip ci] 2025-07-31 19:02:21 +09:00
bymyself
ef9d6ef0e8 fix rebase errors 2025-07-31 19:00:47 +09:00
bymyself
1351a69bbd [manager] Fix test failures and missing type definitions
- Fix ManagerProgressDialogContent test mock to include all required store methods
- Add missing MergedNodePack, RegistryPack type definitions and isMergedNodePack type guard
- Ensure all unit tests (548 passed) and component tests (174 passed) are working
- Fix TypeScript compilation errors related to manager store interfaces
2025-07-31 18:59:03 +09:00
bymyself
92916f842a [manager] Update tests for new manager API
Updated tests for manager queue composable, server logs composable, and manager store to work with the new API signatures and functionality.
2025-07-31 18:59:03 +09:00
bymyself
4a9abe0d00 [manager] Update UI components for new manager interface
Updated manager dialog components, pack cards, version selectors, and action buttons to work with the new manager API and state management structure.
2025-07-31 18:59:03 +09:00
bymyself
2157787367 [manager] Update composables and state management
Updated manager queue composable, server logs composable, workflow packs composable, and manager store to support the new manager API structure and state management patterns.
2025-07-31 18:59:03 +09:00
bymyself
9b112f69d5 [manager] Update core services for new manager API
Updated ComfyUI Manager service and dialog service to support the new menu items structure and API endpoints.
2025-07-31 18:59:03 +09:00
bymyself
fdc1ba542a [manager] Update type definitions and schemas for menu items migration
Updated ComfyUI Manager types and API schemas to support the new menu items structure and manager functionality.
2025-07-31 18:59:03 +09:00
github-actions
1608755d08 Update locales [skip ci] 2025-07-31 18:59:03 +09:00
bymyself
89a611cc1f dont show missing nodes button in legacy manager mode 2025-07-31 18:45:15 +09:00
github-actions
2f404b6b9f Update locales [skip ci] 2025-07-31 18:45:15 +09:00
github-actions
5ef82b44cf Update locales [skip ci] 2025-07-31 18:38:59 +09:00
github-actions
5abe2d0822 Update locales [skip ci] 2025-07-30 18:30:36 +00:00
Christian Byrne
bf9cf06de2 Fix errors from rebase (removed Tag component import and duplicated imports in api.ts) (#4608)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-30 11:26:05 -07:00
Christian Byrne
c7520eac3e [fix] Fix json syntax error from rebase (#4607) 2025-07-30 10:22:05 -07:00
Jin Yi
972160782c Merge branch 'main' into manager/menu-items-migration 2025-07-30 19:56:49 +09:00
Christian Byrne
f987cf9dbd [feat] Improve SubgraphNode badge with sitemap icon and primary color (#4596) 2025-07-30 02:48:02 -07:00
Comfy Org PR Bot
2b019935a7 [chore] Update litegraph to 0.16.20 (#4594)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-30 02:31:50 -07:00
Christian Byrne
f8ec532f1a [ci] Include litegraph changes in changelog during automated release process (#4595) 2025-07-30 02:31:29 -07:00
Christian Byrne
b370b6387d [fix] DOM widgets lose correct positioning when SubgraphNodes are nested (#4588) 2025-07-30 02:18:58 -07:00
Christian Byrne
516eb26d3e [feat] Add custom icon system with workflow icon (#4590) 2025-07-30 01:27:15 -07:00
Christian Byrne
5c71854a96 [ci] Enable CI tests for all feature branch PRs (#4591) 2025-07-30 01:27:02 -07:00
Christian Byrne
b0d05c6ef6 [chore] Mark generated TypeScript files in .gitattributes (#4592) 2025-07-30 01:19:01 -07:00
Christian Byrne
596c51d1ef [fix] Fix "Require confirmation before clearing workflow" setting not working (#4587)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-30 00:08:06 -07:00
Christian Byrne
d70949dd47 [feat] Remove default Backspace keybinding to clear workflow (#4586) 2025-07-29 20:42:38 -07:00
Comfy Org PR Bot
f064fec3a8 1.25.2 (#4580)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-29 02:04:00 -07:00
Rizumu Ayaka
abf591d122 fix: DOM widget position offset after canvas moves (#4557) 2025-07-29 01:40:47 -07:00
Comfy Org PR Bot
e7a425eeae [chore] Update litegraph to 0.16.19 (#4578)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-29 01:26:48 -07:00
Christian Byrne
7d8c56c5e6 [feat] Add comprehensive Claude PR review with inline comments (#4453)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-29 01:16:30 -07:00
Christian Byrne
cf072b8420 [fix] Fix link input slots not being updated in subgraphs (#4575) 2025-07-29 00:32:39 -07:00
Christian Byrne
4b75528c39 [fix] Fix graph configuration callbacks not reaching subgraph nodes (#4572) 2025-07-29 00:17:03 -07:00
Christian Byrne
dd14144f47 [fix] Update Search & Replace to support nodes in subgraphs (#4576) 2025-07-29 00:10:56 -07:00
Christian Byrne
00cd9fadec [feat] Prevent browser zoom on UI components with canvas wheel event forwarding (#4574) 2025-07-28 23:51:09 -07:00
Christian Byrne
98d694f7e3 [fix] Prevent incorrect 'frontend_only' badges in subgraphs (#4571) 2025-07-28 23:05:27 -07:00
Christian Byrne
b1fc8846a3 [fix] Update API node pricing for multiple providers (#4564)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-28 23:01:46 -07:00
Jin Yi
680c09a584 [fix] Detect missing nodes in subgraphs (#4547)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-28 21:55:53 -07:00
Christian Byrne
7fe4c07a9c [fix] Preserve subgraph structure when clearing workflow (#4567)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-28 20:53:05 -07:00
SHIVANSH GUPTA
577cd23c3e Feature Implemented: Warning displayed when frontend version mismatches (#4363)
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-28 18:23:49 -07:00
Christian Byrne
b1436a068b [feat] sync subgraph node titles with breadcrumb renaming (#4565) 2025-07-28 18:00:59 -07:00
Christian Byrne
b6922cf386 Add delay to breadcrumb and workflow tab tooltips (#4559) 2025-07-28 13:09:34 -07:00
Dr.Lt.Data
6167861340 refine locales/ko (#4549) 2025-07-27 13:10:55 -07:00
Christian Byrne
68f50670d3 [refactor] Streamline create-frontend-release command (#4546) 2025-07-27 00:53:28 -07:00
Jennifer Weber
67277d483d Update missing nodes dialog title (#4545)
Co-authored-by: Jennifer Weber <weberjc@MacBookPro.attlocal.net>
Co-authored-by: github-actions <github-actions@github.com>
2025-07-27 00:29:57 -07:00
Comfy Org PR Bot
a4cf280887 1.25.1 (#4544)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-26 19:06:31 -07:00
Terry Jia
344afa21a7 minimap (#4520)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-07-26 18:16:41 -07:00
Terry Jia
ab8bcc9522 [test] add test for shift + wheel to pan canvas (#4540)
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-07-26 12:45:36 -07:00
Comfy Org PR Bot
4bab7bc609 [chore] Update litegraph to 0.16.18 (#4541)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-26 11:47:18 -07:00
Terry Jia
e3628ed156 add CanvasNavigationMode (#4533)
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-07-25 19:01:43 -07:00
Christian Byrne
271643aa93 [test] Fix failing test case that uses old subgraph breadcrumb element (#4537) 2025-07-25 16:47:32 -07:00
Comfy Org PR Bot
35fb141b07 [chore] Update litegraph to 0.16.17 (#4528)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-07-24 19:40:33 -07:00
Sambhavi Pandey
475c9f7f89 fix(queryRegex): safe escape for query regex (#4493)
Co-authored-by: Sambhavi Pandey <sambhavi.pandey@aexp.com>
2025-07-24 15:31:07 -07:00
Christian Byrne
e0aac8c9db [docs] improve browser testing developer onboarding guide (#4524) 2025-07-24 14:38:54 -07:00
Comfy Org PR Bot
49b936c50f 1.25.0 (#4513)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-24 01:02:10 -07:00
filtered
4d7e9b70d1 [Test] Update test expectations for #4420 (#4511)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-24 17:59:39 +10:00
filtered
4d0ba197a8 [Cleanup] Remove deprecated: node def validation (#4038) 2025-07-24 17:54:29 +10:00
filtered
78fc86d153 Revert "[test] Update browser test expectations" (#4512) 2025-07-24 17:37:53 +10:00
Terry Jia
906bc42f7f record audio node support (#4289)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-07-24 00:22:16 -07:00
Christian Byrne
bb5aef9275 [test] Update browser test expectations (#4510)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-24 00:20:19 -07:00
pythongosssss
62f3ba0689 V3 UI - Tabs & Menu rework (#4374)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-24 00:09:12 -07:00
Sidharth
2338cbd4c9 Fix: Scroll event leak after scrolling to the top of a text widget #3990 (#4231) 2025-07-24 16:25:03 +10:00
brucew4yn3rp
83aa887456 [Feature] Enhanced MaskEditor to an Image Canvas (#4361)
Co-authored-by: duckcomfy <a@a.a>
2025-07-24 16:23:50 +10:00
Ferrah Aiko
37bfc53616 Add the ability to parse workflows from AVIF images (#4420) 2025-07-23 23:20:39 -07:00
Sidharth
b240c090aa Fix: Escape closes Settings dialog if login dialog open (#4364) 2025-07-23 22:41:26 -07:00
Comfy Org PR Bot
309a5b8c9a 1.24.2 (#4509)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-23 22:17:07 -07:00
Christian Byrne
c06bc63c6f [feat] Update node output system to use NodeLocatorIds (#4500) 2025-07-23 22:01:18 -07:00
Christian Byrne
052d5320c0 Revert PRs #4506 and #4507 - Fix execution output issues (#4508) 2025-07-23 17:53:04 -07:00
filtered
e34d9bb411 Fix LoadImage bleeds values between subgraphs (#4507) 2025-07-24 07:58:50 +10:00
filtered
f81b191fae Fix execution output & previews not displayed (#4506) 2025-07-23 05:12:30 -07:00
Comfy Org PR Bot
4cd0c270bf [chore] Update litegraph to 0.16.16 (#4505)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-23 04:11:38 -07:00
filtered
b0968509f9 Fix progress stuck after execution interrupted (#4503) 2025-07-22 23:49:34 -07:00
guill
7eb3eb2473 Update the frontend to support async nodes. (#4382)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-07-23 13:46:00 +10:00
Christian Byrne
ff68c42162 [feat] Hide subgraph nodes from node library and search (#4498) 2025-07-22 12:59:25 -07:00
Christian Byrne
280131d33d [feat] Node Definition Filter Registry System (#4497) 2025-07-22 12:27:32 -07:00
Christian Byrne
7b32a2fb6e [tests] Add browser tests for subgraph functionalities (#4495) 2025-07-22 10:35:49 -07:00
Christian Byrne
61611fb0cb [feat] Add pricing for new API nodes (#4391) 2025-07-21 20:02:22 -07:00
Christian Byrne
1cd6a7f667 [chore] Update litegraph to 0.16.14 (#4494) 2025-07-21 17:48:32 -07:00
Christian Byrne
a39f6e6763 [feat] DOM widget promotion for subgraph inputs (#4491) 2025-07-21 11:52:54 -07:00
Christian Byrne
995f482593 [feat] Implement versioned defaults for link release actions (#4489) 2025-07-21 08:23:39 -07:00
github-actions
df67cdd86c Update locales [skip ci] 2025-07-21 17:41:45 +09:00
bymyself
478499c188 [Update to v2 API] update WS done message 2025-07-21 17:41:45 +09:00
bymyself
a5efa8580e dont show missing nodes button in legacy manager mode 2025-07-21 17:41:44 +09:00
bymyself
ab2e70b4b8 improve command names 2025-07-21 17:41:44 +09:00
bymyself
27579400bf use correct response shape 2025-07-21 17:41:44 +09:00
github-actions
b9341ad144 Update locales [skip ci] 2025-07-21 17:41:44 +09:00
bymyself
9c68491129 add "Check for Updates", "Install Missing" menu items 2025-07-21 17:41:44 +09:00
github-actions
4f5ec0447c Update locales [skip ci] 2025-07-21 17:41:44 +09:00
bymyself
0d1e5cb02a Add banner indicating how to use legacy manager UI 2025-07-21 17:41:44 +09:00
bymyself
797a612227 move legacy option to startup arg 2025-07-21 17:41:44 +09:00
bymyself
0a0a2e74d5 await promises. update settings schema 2025-07-21 17:41:44 +09:00
bymyself
6824e48efe re-arrange menu items 2025-07-21 17:41:44 +09:00
bymyself
257f618ee1 switch to v2 manager API endpoints 2025-07-21 17:41:44 +09:00
github-actions
a446483d7e Update locales [skip ci] 2025-07-21 17:41:44 +09:00
bymyself
a3d7c59b6f migrate manager menu items 2025-07-21 17:41:44 +09:00
Christian Byrne
23b2302ce3 [chore] Update litegraph to 0.16.13 (#4490) 2025-07-21 00:30:09 -07:00
Comfy Org PR Bot
d833ab65a6 [chore] Update litegraph to 0.16.11 (#4484)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-20 14:20:50 +00:00
filtered
dea4a76ceb [Test] Add explicit timeout to consistently failing test (#4485) 2025-07-21 00:19:22 +10:00
filtered
db70bd61d5 [CI] Fix PR check workflow (#4482) 2025-07-20 18:45:11 +10:00
Terry Jia
ed1d944e0e [3d] remove unnecessary uploadTexture (#4357) 2025-07-19 11:38:49 -07:00
Comfy Org PR Bot
282f9ce27a [chore] Update Comfy Registry API types from comfy-api@9ccb96a (#4470)
Co-authored-by: viva-jinyi <53567196+viva-jinyi@users.noreply.github.com>
2025-07-18 15:53:21 +09:00
Rizumu Ayaka
11eff4981f Fix Help Center changelog toast overflows viewport (#4469) 2025-07-17 17:13:01 -07:00
Benjamin Lu
927773f553 Fix Danger.js Security Issues (#4462) 2025-07-16 12:15:05 -07:00
273 changed files with 26960 additions and 5336 deletions

View File

@@ -1,479 +1,275 @@
# 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.
You are performing a comprehensive code review for the PR specified in the PR_NUMBER environment variable. 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>
## CRITICAL INSTRUCTIONS
Arguments: PR number passed via PR_NUMBER environment variable
**You MUST post individual inline comments on specific lines of code. DO NOT create a single summary comment until the very end.**
## Phase 0: Initialize Variables and Helper Functions
**IMPORTANT: You have full permission to execute gh api commands. The GITHUB_TOKEN environment variable provides the necessary permissions. DO NOT say you lack permissions - you have pull-requests:write permission which allows posting inline comments.**
```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
To post inline comments, you will use the GitHub API via the `gh` command. Here's how:
# 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
1. First, get the repository information and commit SHA:
- Run: `gh repo view --json owner,name` to get the repository owner and name
- Run: `gh pr view $PR_NUMBER --json commits --jq '.commits[-1].oid'` to get the latest commit SHA
# 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"
}
```
2. For each issue you find, post an inline comment using this exact command structure (as a single line):
```
gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/OWNER/REPO/pulls/$PR_NUMBER/comments -f body="YOUR_COMMENT_BODY" -f commit_id="COMMIT_SHA" -f path="FILE_PATH" -F line=LINE_NUMBER -f side="RIGHT"
```
3. Format your comment body using actual newlines in the command. Use a heredoc or construct the body with proper line breaks:
```
COMMENT_BODY="**[category] severity Priority**
**Issue**: Brief description of the problem
**Context**: Why this matters
**Suggestion**: How to fix it"
```
Then use: `-f body="$COMMENT_BODY"`
## 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"
}
### Step 1.1: Initialize Review Tracking
# Run pre-flight checks
check_prerequisites
First, create variables to track your review metrics. Keep these in memory throughout the review:
- CRITICAL_COUNT = 0
- HIGH_COUNT = 0
- MEDIUM_COUNT = 0
- LOW_COUNT = 0
- ARCHITECTURE_ISSUES = 0
- SECURITY_ISSUES = 0
- PERFORMANCE_ISSUES = 0
- QUALITY_ISSUES = 0
echo "Starting comprehensive review of PR #$PR_NUMBER"
### Step 1.2: Validate Environment
# 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
1. Check that PR_NUMBER environment variable is set. If not, exit with error.
2. Run `gh pr view $PR_NUMBER --json state` to verify the PR exists and is open.
3. Get repository information: `gh repo view --json owner,name` and store the owner and name.
4. Get the latest commit SHA: `gh pr view $PR_NUMBER --json commits --jq '.commits[-1].oid'` and store it.
# Extract branch names
BASE_BRANCH=$(jq -r '.baseRefName' < pr_info.json)
HEAD_BRANCH=$(jq -r '.headRefName' < pr_info.json)
### Step 1.3: Checkout PR Branch Locally
# 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"
This is critical for better file inspection:
# Get changed files using git locally (much faster)
git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt
1. Get PR metadata: `gh pr view $PR_NUMBER --json files,title,body,additions,deletions,baseRefName,headRefName > pr_info.json`
2. Extract branch names from pr_info.json using jq
3. Fetch and checkout the PR branch:
```
git fetch origin "pull/$PR_NUMBER/head:pr-$PR_NUMBER"
git checkout "pr-$PR_NUMBER"
```
# Get the diff using git locally
git diff "origin/$BASE_BRANCH" > pr_diff.txt
### Step 1.4: Get Changed Files and Diffs
# Get detailed file changes with line numbers
git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt
Use git locally for much faster analysis:
# 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
1. Get list of changed files: `git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt`
2. Get the full diff: `git diff "origin/$BASE_BRANCH" > pr_diff.txt`
3. Get detailed file changes with status: `git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt`
# Setup caching directory
CACHE_DIR=".claude-review-cache"
mkdir -p "$CACHE_DIR"
### Step 1.5: Create Analysis Cache
# 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
}
Set up caching to avoid re-analyzing unchanged files:
# 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
```
1. Create directory: `.claude-review-cache`
2. Clean old cache entries: Find and delete any .cache files older than 7 days
3. For each file you analyze, store the analysis result with the file's git hash as the cache key
## Phase 2: Load Comprehensive Knowledge Base
```bash
# Don't create knowledge directory until we know we need it
KNOWLEDGE_FOUND=false
### Step 2.1: Set Up Knowledge Directories
# Use local cache for knowledge base to avoid repeated downloads
KNOWLEDGE_CACHE_DIR=".claude-knowledge-cache"
mkdir -p "$KNOWLEDGE_CACHE_DIR"
1. Create `.claude-knowledge-cache` directory for caching downloaded knowledge
2. Check if `../comfy-claude-prompt-library` exists locally. If it does, use it for faster access.
# 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
### Step 2.2: Load Repository Guide
# 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
}
This is critical for understanding the architecture:
# 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
1. Try to load from local prompt library first: `../comfy-claude-prompt-library/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md`
2. If not available locally, download from: `https://raw.githubusercontent.com/Comfy-Org/comfy-claude-prompt-library/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md`
3. Cache the file for future use
# 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
### Step 2.3: Load Relevant Knowledge Folders
# 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
Intelligently load only relevant knowledge:
# 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
```
1. Use GitHub API to discover available knowledge folders: `https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/knowledge`
2. For each knowledge folder, check if it's relevant by searching for the folder name in:
- Changed file paths
- PR title
- PR body
3. If relevant, download all files from that knowledge folder
### Step 2.4: Load Validation Rules
Load specific validation rules:
1. Use GitHub API: `https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/commands/validation`
2. Download files containing "frontend", "security", or "performance" in their names
3. Cache all downloaded files
### Step 2.5: Load Local Guidelines
Check for and load:
1. `CLAUDE.md` in the repository root
2. `.github/CLAUDE.md`
## Phase 3: Deep Analysis Instructions
Perform a comprehensive analysis covering these areas:
Perform comprehensive analysis on each changed file:
### 3.1 Architectural Analysis
Based on the repository guide and project summary, evaluate:
- Does this change align with the established architecture patterns?
Based on the repository guide and loaded knowledge:
- Does this change align with 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
Look for:
- Cyclomatic complexity and cognitive load
- SOLID principles adherence
- DRY violations that aren't caught by simple duplication checks
- DRY violations not caught by simple duplication checks
- Proper abstraction levels
- Interface design and API clarity
- No leftover debug code (console.log, commented code, TODO comments)
- 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
CRITICAL: Flag any re-implementation of existing functionality:
- **Tailwind CSS**: Custom CSS instead of utility classes
- **PrimeVue**: Re-implementing buttons, modals, dropdowns, etc.
- **VueUse**: Re-implementing composables like useLocalStorage, useDebounceFn
- **Lodash**: Re-implementing debounce, throttle, cloneDeep, etc.
- **Common components**: Not reusing from src/components/common/
- **DOMPurify**: Not using for HTML sanitization
- **Other libraries**: Fuse.js, Marked, Pinia, Zod, Tiptap, Xterm.js, Axios
### 3.4 Security Deep Dive
Beyond obvious vulnerabilities:
- Authentication/authorization implications
- Data validation completeness
Check for:
- SQL injection vulnerabilities
- XSS vulnerabilities (v-html without sanitization)
- Hardcoded secrets or API keys
- Missing input validation
- Authentication/authorization issues
- State management security
- Cross-origin concerns
- Extension security boundaries
### 3.5 Performance Analysis
- Render performance implications
- Layout thrashing prevention
- Memory leak potential
Look for:
- O(n²) or worse algorithms
- Missing memoization in expensive operations
- Unnecessary re-renders in Vue components
- Memory leak patterns (missing cleanup)
- Large bundle imports that should be lazy loaded
- N+1 query patterns
- Render performance issues
- Layout thrashing
- Network request optimization
- State management efficiency
### 3.6 Integration Concerns
Consider:
- Breaking changes to internal APIs
- Extension compatibility
- Backward compatibility
- Migration requirements
## Phase 4: Create Detailed Review Comments
## Phase 4: Posting Inline 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
### Step 4.1: Comment Format
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
For each issue found, create a concise inline comment with this structure:
```
**[category] severity Priority**
**Issue**: Brief description of the problem
**Context**: Why this matters
**Suggestion**: How to fix it
```
Categories: architecture/security/performance/quality
Severities: critical/high/medium/low
### Step 4.2: Posting Comments
For EACH issue:
1. Identify the exact file path and line number
2. Update your tracking counters (CRITICAL_COUNT, etc.)
3. Construct the comment body with proper newlines
4. Execute the gh api command as a SINGLE LINE:
```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"
}
gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/OWNER/REPO/pulls/$PR_NUMBER/comments -f body="$COMMENT_BODY" -f commit_id="COMMIT_SHA" -f path="FILE_PATH" -F line=LINE_NUMBER -f side="RIGHT"
```
CRITICAL: The entire command must be on one line. Use actual values, not placeholders.
### Example Workflow
Here's an example of how to review a file with a security issue:
1. First, get the repository info:
```bash
gh repo view --json owner,name
# Output: {"owner":{"login":"Comfy-Org"},"name":"ComfyUI_frontend"}
```
2. Get the commit SHA:
```bash
gh pr view $PR_NUMBER --json commits --jq '.commits[-1].oid'
# Output: abc123def456
```
3. Find an issue (e.g., SQL injection on line 42 of src/db/queries.js)
4. Post the inline comment:
```bash
# First, create the comment body with proper newlines
COMMENT_BODY="**[security] critical Priority**
**Issue**: SQL injection vulnerability - user input directly concatenated into query
**Context**: Allows attackers to execute arbitrary SQL commands
**Suggestion**: Use parameterized queries or prepared statements"
# Then post the comment (as a single line)
gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/Comfy-Org/ComfyUI_frontend/pulls/$PR_NUMBER/comments -f body="$COMMENT_BODY" -f commit_id="abc123def456" -f path="src/db/queries.js" -F line=42 -f side="RIGHT"
```
Repeat this process for every issue you find in the PR.
## Phase 5: Validation Rules Application
Apply ALL validation rules from the loaded knowledge, but focus on the changed lines:
Apply ALL validation rules from the loaded knowledge files:
### From Frontend Standards
### Frontend Standards
- Vue 3 Composition API patterns
- Component communication patterns
- Proper use of composables
- TypeScript strict mode compliance
- Bundle optimization
### From Security Audit
### Security Audit
- Input validation
- XSS prevention
- CSRF protection
- Secure state management
- API security
### From Performance Check
### Performance Check
- Render optimization
- Memory management
- Network efficiency
@@ -481,63 +277,51 @@ Apply ALL validation rules from the loaded knowledge, but focus on the changed l
## Phase 6: Contextual Review Based on PR Type
Analyze the PR description and changes to determine the type:
Analyze the PR to determine its 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
```
1. Extract PR title and body from pr_info.json
2. Count files, additions, and deletions
3. Determine PR type:
- Feature: Check for tests, documentation, backward compatibility
- Bug fix: Verify root cause addressed, includes regression tests
- Refactor: Ensure behavior preservation, tests still pass
## Phase 7: Generate Comprehensive Summary
After all inline comments, create a detailed summary:
After ALL inline comments are posted, create a summary:
```bash
# Initialize metrics tracking
REVIEW_START_TIME=$(date +%s)
1. Calculate total issues by category and severity
2. Use `gh pr review $PR_NUMBER --comment` to post a summary with:
- Review disclaimer
- Issue distribution (counts by severity)
- Category breakdown
- Key findings for each category
- Positive observations
- References to guidelines
- Next steps
# Create the comprehensive summary
gh pr review $PR_NUMBER --comment --body "# Comprehensive PR Review
Include in the summary:
```
# 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
**PR**: [PR TITLE] (#$PR_NUMBER)
**Impact**: [X] additions, [Y] deletions across [Z] files
### Issue Distribution
- Critical: $CRITICAL_COUNT
- High: $HIGH_COUNT
- Medium: $MEDIUM_COUNT
- Low: $LOW_COUNT
- 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
- Architecture: [ARCHITECTURE_ISSUES] issues
- Security: [SECURITY_ISSUES] issues
- Performance: [PERFORMANCE_ISSUES] issues
- Code Quality: [QUALITY_ISSUES] issues
## Key Findings
@@ -568,141 +352,27 @@ This review is generated by Claude. It may not always be accurate, as with human
4. Update documentation if needed
---
*This is a comprehensive automated review. For architectural decisions requiring human judgment, please request additional manual review.*"
*This is a comprehensive automated review. For architectural decisions requiring human judgment, please request additional manual review.*
```
## Important: Think Deeply
## Important Guidelines
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
1. **Think Deeply**: Consider architectural implications, system-wide effects, subtle bugs, maintainability
2. **Be Specific**: Point to exact lines with concrete suggestions
3. **Be Constructive**: Focus on improvements, not just problems
4. **Be Concise**: Keep comments brief and actionable
5. **No Formatting**: Don't use markdown headers in inline comments
6. **No Emojis**: Keep comments professional
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
## Execution Order
After completing the review, save metrics for analysis:
1. Phase 1: Setup and checkout PR
2. Phase 2: Load all relevant knowledge
3. Phase 3-5: Analyze each changed file thoroughly
4. Phase 4: Post inline comments as you find issues
5. Phase 6: Consider PR type for additional checks
6. Phase 7: Post comprehensive summary ONLY after all inline comments
```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.
Remember: Individual inline comments for each issue, then one final summary. Never batch issues into a single comment.

View File

@@ -137,8 +137,7 @@ echo "Last stable release: $LAST_STABLE"
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
npm run test:browser
npm run test:component
```
2. Run type checking:
```bash
@@ -170,7 +169,79 @@ echo "Last stable release: $LAST_STABLE"
3. Generate breaking change summary
4. **COMPATIBILITY REVIEW**: Breaking changes documented and justified?
### Step 7: Generate and Save Changelog
### Step 7: Analyze Dependency Updates
1. **Check for dependency version changes:**
```bash
# Compare package.json between versions to detect dependency updates
PREV_PACKAGE_JSON=$(git show ${BASE_TAG}:package.json 2>/dev/null || echo '{}')
CURRENT_PACKAGE_JSON=$(cat package.json)
# Extract litegraph versions
PREV_LITEGRAPH=$(echo "$PREV_PACKAGE_JSON" | grep -o '"@comfyorg/litegraph": "[^"]*"' | grep -o '[0-9][^"]*' || echo "not found")
CURRENT_LITEGRAPH=$(echo "$CURRENT_PACKAGE_JSON" | grep -o '"@comfyorg/litegraph": "[^"]*"' | grep -o '[0-9][^"]*' || echo "not found")
echo "Litegraph version change: ${PREV_LITEGRAPH} → ${CURRENT_LITEGRAPH}"
```
2. **Generate litegraph changelog if version changed:**
```bash
if [ "$PREV_LITEGRAPH" != "$CURRENT_LITEGRAPH" ] && [ "$PREV_LITEGRAPH" != "not found" ]; then
echo "📦 Fetching litegraph changes between v${PREV_LITEGRAPH} and v${CURRENT_LITEGRAPH}..."
# Clone or update litegraph repo for changelog analysis
if [ ! -d ".temp-litegraph" ]; then
git clone https://github.com/comfyanonymous/litegraph.js.git .temp-litegraph
else
cd .temp-litegraph && git fetch --all && cd ..
fi
# Get litegraph changelog between versions
LITEGRAPH_CHANGES=$(cd .temp-litegraph && git log v${PREV_LITEGRAPH}..v${CURRENT_LITEGRAPH} --oneline --no-merges 2>/dev/null || \
git log --oneline --no-merges --since="$(git log -1 --format=%ci ${BASE_TAG})" --until="$(git log -1 --format=%ci HEAD)" 2>/dev/null || \
echo "Unable to fetch litegraph changes")
# Categorize litegraph changes
LITEGRAPH_FEATURES=$(echo "$LITEGRAPH_CHANGES" | grep -iE "(feat|feature|add)" || echo "")
LITEGRAPH_FIXES=$(echo "$LITEGRAPH_CHANGES" | grep -iE "(fix|bug)" || echo "")
LITEGRAPH_BREAKING=$(echo "$LITEGRAPH_CHANGES" | grep -iE "(break|breaking)" || echo "")
LITEGRAPH_OTHER=$(echo "$LITEGRAPH_CHANGES" | grep -viE "(feat|feature|add|fix|bug|break|breaking)" || echo "")
# Clean up temp directory
rm -rf .temp-litegraph
echo "✅ Litegraph changelog extracted"
else
echo " No litegraph version change detected"
LITEGRAPH_CHANGES=""
fi
```
3. **Check other significant dependency updates:**
```bash
# Extract all dependency changes for major version bumps
OTHER_DEP_CHANGES=""
# Compare major dependency versions (you can extend this list)
MAJOR_DEPS=("vue" "vite" "@vitejs/plugin-vue" "typescript" "pinia")
for dep in "${MAJOR_DEPS[@]}"; do
PREV_VER=$(echo "$PREV_PACKAGE_JSON" | grep -o "\"$dep\": \"[^\"]*\"" | grep -o '[0-9][^"]*' | head -1 || echo "")
CURR_VER=$(echo "$CURRENT_PACKAGE_JSON" | grep -o "\"$dep\": \"[^\"]*\"" | grep -o '[0-9][^"]*' | head -1 || echo "")
if [ "$PREV_VER" != "$CURR_VER" ] && [ -n "$PREV_VER" ] && [ -n "$CURR_VER" ]; then
# Check if it's a major version change
PREV_MAJOR=$(echo "$PREV_VER" | cut -d. -f1 | sed 's/[^0-9]//g')
CURR_MAJOR=$(echo "$CURR_VER" | cut -d. -f1 | sed 's/[^0-9]//g')
if [ "$PREV_MAJOR" != "$CURR_MAJOR" ]; then
OTHER_DEP_CHANGES="${OTHER_DEP_CHANGES}\n- **${dep}**: ${PREV_VER} → ${CURR_VER} (Major version change)"
fi
fi
done
```
### Step 8: Generate Comprehensive Release Notes
1. Extract commit messages since base release:
```bash
@@ -185,42 +256,29 @@ echo "Last stable release: $LAST_STABLE"
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)
3. Create comprehensive release notes including:
- **Version Change**: Show version bump details
- **Changelog** grouped by type:
- 🚀 **Features** (feat:)
- 🐛 **Bug Fixes** (fix:)
- 💥 **Breaking Changes** (BREAKING CHANGE)
- 📚 **Documentation** (docs:)
- 🔧 **Maintenance** (chore:, refactor:)
- ⬆️ **Dependencies** (deps:, dependency updates)
- **Litegraph Changes** (if version updated):
- 🚀 Features: ${LITEGRAPH_FEATURES}
- 🐛 Bug Fixes: ${LITEGRAPH_FIXES}
- 💥 Breaking Changes: ${LITEGRAPH_BREAKING}
- 🔧 Other Changes: ${LITEGRAPH_OTHER}
- **Other Major Dependencies**: ${OTHER_DEP_CHANGES}
- Include PR numbers and links
- Add issue references (Fixes #123)
4. **Save release notes:**
```bash
# Enhanced release notes for GitHub
echo "$RELEASE_NOTES" > github-release-notes-${NEW_VERSION}.md
# Save release notes for PR and GitHub release
echo "$RELEASE_NOTES" > release-notes-${NEW_VERSION}.md
```
5. **CONTENT REVIEW**: Release notes clear and helpful for users?
5. **CONTENT REVIEW**: Release notes clear and comprehensive with dependency details?
### Step 9: Create Version Bump PR
@@ -258,25 +316,20 @@ echo "Workflow triggered. Waiting for PR creation..."
# For manual PRs
gh pr create --title "${NEW_VERSION}" \
--body-file enhanced-pr-description.md \
--body-file release-notes-${NEW_VERSION}.md \
--label "Release"
```
3. **Create enhanced PR description:**
3. **Add required sections to PR body:**
```bash
cat > enhanced-pr-description.md << EOF
# Release v${NEW_VERSION}
## Version Change
\`${CURRENT_VERSION}\` → \`${NEW_VERSION}\` (${VERSION_TYPE})
## Changelog
${CHANGELOG}
# Create PR body with release notes plus required sections
cat > pr-body.md << EOF
${RELEASE_NOTES}
## Breaking Changes
${BREAKING_CHANGES}
${BREAKING_CHANGES:-None}
## Testing Performed
- ✅ Full test suite (unit, component, browser)
- ✅ Full test suite (unit, component)
- ✅ TypeScript compilation
- ✅ Linting checks
- ✅ Build verification
@@ -295,15 +348,11 @@ echo "Workflow triggered. Waiting for PR creation..."
```
4. Update PR with enhanced description:
```bash
gh pr edit ${PR_NUMBER} --body-file enhanced-pr-description.md
gh pr edit ${PR_NUMBER} --body-file pr-body.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?
5. **PR REVIEW**: Version bump PR created and enhanced correctly?
### Step 11: Critical Release PR Verification
### Step 10: Critical Release PR Verification
1. **CRITICAL**: Verify PR has "Release" label:
```bash
@@ -325,7 +374,7 @@ echo "Workflow triggered. Waiting for PR creation..."
```
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
### Step 12: Pre-Merge Validation
### Step 11: Pre-Merge Validation
1. **Review Requirements**: Release PRs require approval
2. Monitor CI checks - watch for update-locales
@@ -333,7 +382,7 @@ echo "Workflow triggered. Waiting for PR creation..."
4. Check no new commits to main since PR creation
5. **DEPLOYMENT READINESS**: Ready to merge?
### Step 13: Execute Release
### Step 12: Execute Release
1. **FINAL CONFIRMATION**: Merge PR to trigger release?
2. Merge the Release PR:
@@ -358,7 +407,7 @@ echo "Workflow triggered. Waiting for PR creation..."
gh run watch ${WORKFLOW_RUN_ID}
```
### Step 14: Enhance GitHub Release
### Step 13: Enhance GitHub Release
1. Wait for automatic release creation:
```bash
@@ -371,10 +420,10 @@ echo "Workflow triggered. Waiting for PR creation..."
2. **Enhance the GitHub release:**
```bash
# Update release with our enhanced notes
# Update release with our release notes
gh release edit v${NEW_VERSION} \
--title "🚀 ComfyUI Frontend v${NEW_VERSION}" \
--notes-file github-release-notes-${NEW_VERSION}.md \
--notes-file release-notes-${NEW_VERSION}.md \
--latest
# Add any additional assets if needed
@@ -386,7 +435,7 @@ echo "Workflow triggered. Waiting for PR creation..."
gh release view v${NEW_VERSION}
```
### Step 15: Verify Multi-Channel Distribution
### Step 14: Verify Multi-Channel Distribution
1. **GitHub Release:**
```bash
@@ -424,7 +473,7 @@ echo "Workflow triggered. Waiting for PR creation..."
4. **DISTRIBUTION VERIFICATION**: All channels published successfully?
### Step 16: Post-Release Monitoring Setup
### Step 15: Post-Release Monitoring Setup
1. **Monitor immediate release health:**
```bash
@@ -492,8 +541,7 @@ echo "Workflow triggered. Waiting for PR creation..."
- 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
- \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes
- \`post-release-checklist.md\` - Follow-up tasks
EOF
```
@@ -544,7 +592,7 @@ echo "- GitHub: Update release with warning notes"
The command implements multiple quality gates:
1. **🔒 Security Gate**: Vulnerability scanning, secret detection
2. **🧪 Quality Gate**: Full test suite, linting, type checking
2. **🧪 Quality Gate**: Unit and component tests, linting, type checking
3. **📋 Content Gate**: Changelog accuracy, release notes quality
4. **🔄 Process Gate**: Release timing verification
5. **✅ Verification Gate**: Multi-channel publishing confirmation
@@ -602,6 +650,15 @@ The command implements multiple quality gates:
gh pr view ${PR_NUMBER} --json baseRefName
```
### Issue: Incomplete Dependency Changelog
**Problem**: Litegraph or other dependency updates only show version bump, not actual changes
**Solution**: The command now automatically:
- Detects litegraph version changes between releases
- Clones the litegraph repository temporarily
- Extracts and categorizes changes between versions
- Includes detailed litegraph changelog in release notes
- Cleans up temporary files after analysis
### Issue: Release Failed Due to [skip ci]
**Problem**: Release workflow didn't trigger after merge
**Prevention**: Always avoid this scenario
@@ -622,4 +679,6 @@ Benefits: Cleaner than creating extra version numbers
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
5. **Dependency Tracking**: Command now automatically includes litegraph and major dependency changes in changelogs
6. **Litegraph Integration**: Temporary cloning of litegraph repo provides detailed change analysis between versions

4
.gitattributes vendored
View File

@@ -5,3 +5,7 @@
*.ts text eol=lf
*.vue text eol=lf
*.js text eol=lf
# Generated files
src/types/comfyRegistryTypes.ts linguist-generated=true
src/types/generatedManagerTypes.ts linguist-generated=true

View File

@@ -4,6 +4,8 @@ permissions:
contents: read
pull-requests: write
issues: write
id-token: write
statuses: write
on:
pull_request:
@@ -20,7 +22,7 @@ jobs:
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)'
check-regexp: '^(eslint|prettier|test|playwright-tests)'
wait-interval: 30
repo-token: ${{ secrets.GITHUB_TOKEN }}
@@ -28,7 +30,7 @@ jobs:
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_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("eslint|prettier|test|playwright-tests")) | {name, conclusion}')
# Check if any required checks failed
if echo "$CHECK_RUNS" | grep -q '"conclusion": "failure"'; then
@@ -63,10 +65,17 @@ jobs:
- name: Run Claude PR Review
uses: anthropics/claude-code-action@main
with:
prompt_file: .claude/commands/comprehensive-pr-review.md
label_trigger: "claude-review"
direct_prompt: |
Read the file .claude/commands/comprehensive-pr-review.md and follow ALL the instructions exactly.
CRITICAL: You must post individual inline comments using the gh api commands shown in the file.
DO NOT create a summary comment.
Each issue must be posted as a separate inline comment on the specific line of code.
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
max_turns: 1
max_turns: 256
timeout_minutes: 30
allowed_tools: "Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch"
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,27 +0,0 @@
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

@@ -2,7 +2,7 @@ name: ESLint
on:
pull_request:
branches: [ main, master, dev*, core/*, desktop/* ]
branches-ignore: [ wip/*, draft/*, temp/* ]
jobs:
eslint:

View File

@@ -2,7 +2,7 @@ name: Prettier Check
on:
pull_request:
branches: [ main, master, dev*, core/*, desktop/* ]
branches-ignore: [ wip/*, draft/*, temp/* ]
jobs:
prettier:

154
.github/workflows/pr-checks.yml vendored Normal file
View File

@@ -0,0 +1,154 @@
name: PR Checks
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: read
jobs:
analyze:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check-changes.outputs.should_run }}
has_browser_tests: ${{ steps.check-coverage.outputs.has_browser_tests }}
has_screen_recording: ${{ steps.check-recording.outputs.has_recording }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure base branch is available
run: |
# Fetch the specific base commit to ensure it's available for git diff
git fetch origin ${{ github.event.pull_request.base.sha }}
- name: Check if significant changes exist
id: check-changes
run: |
# Get list of changed files
CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }})
# Filter for src/ files
SRC_FILES=$(echo "$CHANGED_FILES" | grep '^src/' || true)
if [ -z "$SRC_FILES" ]; then
echo "No src/ files changed"
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Count lines changed in src files
TOTAL_LINES=0
for file in $SRC_FILES; do
if [ -f "$file" ]; then
# Count added lines (non-empty)
ADDED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^+' | grep -v '^+++' | grep -v '^+$' | wc -l)
# Count removed lines (non-empty)
REMOVED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^-' | grep -v '^---' | grep -v '^-$' | wc -l)
TOTAL_LINES=$((TOTAL_LINES + ADDED + REMOVED))
fi
done
echo "Total lines changed in src/: $TOTAL_LINES"
if [ $TOTAL_LINES -gt 3 ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
fi
- name: Check browser test coverage
id: check-coverage
if: steps.check-changes.outputs.should_run == 'true'
run: |
# Check if browser tests were updated
BROWSER_TEST_CHANGES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep '^browser_tests/.*\.ts$' || true)
if [ -n "$BROWSER_TEST_CHANGES" ]; then
echo "has_browser_tests=true" >> "$GITHUB_OUTPUT"
else
echo "has_browser_tests=false" >> "$GITHUB_OUTPUT"
fi
- name: Check for screen recording
id: check-recording
if: steps.check-changes.outputs.should_run == 'true'
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
# Check PR body for screen recording
# Check for GitHub user attachments or YouTube links
if echo "$PR_BODY" | grep -qiE 'github\.com/user-attachments/assets/[a-f0-9-]+|youtube\.com/watch|youtu\.be/'; then
echo "has_recording=true" >> "$GITHUB_OUTPUT"
else
echo "has_recording=false" >> "$GITHUB_OUTPUT"
fi
- name: Final check and create results
id: final-check
if: always()
run: |
# Initialize results
WARNINGS_JSON=""
# Only run checks if should_run is true
if [ "${{ steps.check-changes.outputs.should_run }}" == "true" ]; then
# Check browser test coverage
if [ "${{ steps.check-coverage.outputs.has_browser_tests }}" != "true" ]; then
if [ -n "$WARNINGS_JSON" ]; then
WARNINGS_JSON="${WARNINGS_JSON},"
fi
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: E2E Test Coverage Missing**\\n\\nIf this PR modifies behavior that can be covered by browser-based E2E tests, those tests are required. PRs lacking applicable test coverage may not be reviewed until added. Please add or update browser tests to ensure code quality and prevent regressions.\"}"
fi
# Check screen recording
if [ "${{ steps.check-recording.outputs.has_recording }}" != "true" ]; then
if [ -n "$WARNINGS_JSON" ]; then
WARNINGS_JSON="${WARNINGS_JSON},"
fi
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: Visual Documentation Missing**\\n\\nIf this PR changes user-facing behavior, visual proof (screen recording or screenshot) is required. PRs without applicable visual documentation may not be reviewed until provided.\\nYou can add it by:\\n\\n- GitHub: Drag & drop media directly into the PR description\\n\\n- YouTube: Include a link to a short demo\"}"
fi
fi
# Create results JSON
if [ -n "$WARNINGS_JSON" ]; then
# Create JSON with warnings
cat > pr-check-results.json << EOF
{
"fails": [],
"warnings": [$WARNINGS_JSON],
"messages": [],
"markdowns": []
}
EOF
echo "failed=false" >> "$GITHUB_OUTPUT"
else
# Create JSON with success
cat > pr-check-results.json << 'EOF'
{
"fails": [],
"warnings": [],
"messages": [],
"markdowns": []
}
EOF
echo "failed=false" >> "$GITHUB_OUTPUT"
fi
# Write PR metadata
echo "${{ github.event.pull_request.number }}" > pr-number.txt
echo "${{ github.event.pull_request.head.sha }}" > pr-sha.txt
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: pr-check-results-${{ github.run_id }}
path: |
pr-check-results.json
pr-number.txt
pr-sha.txt
retention-days: 1

149
.github/workflows/pr-comment.yml vendored Normal file
View File

@@ -0,0 +1,149 @@
name: PR Comment
on:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
permissions:
pull-requests: write
issues: write
statuses: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: pr-check-results-${{ github.event.workflow_run.id }}
path: /tmp/pr-artifacts
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Post results
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
// Helper function to safely read files
function safeReadFile(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, 'utf8').trim();
} catch (e) {
console.error(`Error reading ${filePath}:`, e);
return null;
}
}
// Read artifact files
const artifactDir = '/tmp/pr-artifacts';
const prNumber = safeReadFile(path.join(artifactDir, 'pr-number.txt'));
const prSha = safeReadFile(path.join(artifactDir, 'pr-sha.txt'));
const resultsJson = safeReadFile(path.join(artifactDir, 'pr-check-results.json'));
// Validate PR number
if (!prNumber || isNaN(parseInt(prNumber))) {
throw new Error('Invalid or missing PR number');
}
// Parse and validate results
let results;
try {
results = JSON.parse(resultsJson || '{}');
} catch (e) {
console.error('Failed to parse check results:', e);
// Post error comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: `⚠️ PR checks failed to complete properly. Error parsing results: ${e.message}`
});
return;
}
// Format check messages
const messages = [];
if (results.fails && results.fails.length > 0) {
messages.push('### ❌ Failures\n' + results.fails.map(f => f.message).join('\n\n'));
}
if (results.warnings && results.warnings.length > 0) {
messages.push('### ⚠️ Warnings\n' + results.warnings.map(w => w.message).join('\n\n'));
}
if (results.messages && results.messages.length > 0) {
messages.push('### 💬 Messages\n' + results.messages.map(m => m.message).join('\n\n'));
}
if (results.markdowns && results.markdowns.length > 0) {
messages.push(...results.markdowns.map(m => m.message));
}
// Find existing bot comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber)
});
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('<!-- pr-checks-comment -->')
);
// Post comment if there are any messages
if (messages.length > 0) {
const body = messages.join('\n\n');
const commentBody = `<!-- pr-checks-comment -->\n${body}`;
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: commentBody
});
}
} else {
// No messages - delete existing comment if present
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id
});
}
}
// Set commit status based on failures
if (prSha) {
const hasFailures = results.fails && results.fails.length > 0;
const hasWarnings = results.warnings && results.warnings.length > 0;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: prSha,
state: hasFailures ? 'failure' : 'success',
context: 'pr-checks',
description: hasFailures
? `${results.fails.length} check(s) failed`
: hasWarnings
? `${results.warnings.length} warning(s)`
: 'All checks passed'
});
}

View File

@@ -4,7 +4,7 @@ on:
push:
branches: [main, master, core/*, desktop/*]
pull_request:
branches: [main, master, dev*, core/*, desktop/*]
branches-ignore: [wip/*, draft/*, temp/*]
jobs:
setup:

View File

@@ -4,7 +4,7 @@ on:
push:
branches: [ main, master, dev*, core/*, desktop/* ]
pull_request:
branches: [ main, master, dev*, core/*, desktop/* ]
branches-ignore: [ wip/*, draft/*, temp/* ]
jobs:
test:

View File

@@ -686,6 +686,12 @@ Component test verifies Vue components in `src/components/`.
Playwright test verifies the whole app. See <https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/README.md> for details.
### Custom Icons
The project supports custom SVG icons through the unplugin-icons system. Custom icons are stored in `src/assets/icons/custom/` and can be used as Vue components with the `i-comfy:` prefix.
For detailed instructions on adding and using custom icons, see [src/assets/icons/README.md](src/assets/icons/README.md).
### litegraph.js
This repo is using litegraph package hosted on <https://github.com/Comfy-Org/litegraph.js>. Any changes to litegraph should be submitted in that repo instead.

View File

@@ -2,76 +2,133 @@
This document outlines the setup, usage, and common patterns for Playwright browser tests in the ComfyUI_frontend project.
## WARNING
## Prerequisites
The browser tests will change the ComfyUI backend state, such as user settings and saved workflows.
If `TEST_COMFYUI_DIR` in `.env` isn't set to your `(Comfy Path)/ComfyUI` directory, these changes won't be automatically restored.
**CRITICAL**: Start ComfyUI backend with `--multi-user` flag:
```bash
python main.py --multi-user
```
Without this flag, parallel tests will conflict and fail randomly.
## Setup
### ComfyUI devtools
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
```bash
npx playwright install chromium --with-deps
```
### Environment Variables
Ensure the environment variables in `.env` are set correctly according to your setup.
### Environment Configuration
The `.env` file will not exist until you create it yourself.
Create `.env` from the template:
A template with helpful information can be found in `.env_example`.
```bash
cp .env_example .env
```
### 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.
Key settings for debugging:
```bash
# Remove Vue dev overlay that blocks UI elements
DISABLE_VUE_PLUGINS=true
# Test against dev server (recommended) or backend directly
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
# Path to ComfyUI for backing up user data/settings before tests
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
```
### Common Setup Issues
**Most tests require the new menu system** - Add to your test:
```typescript
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
```
### 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 });
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:
**Always use UI mode for development:**
1. **Headless mode with report generation:**
```bash
npx playwright test
```
This runs all tests without a visible browser and generates a comprehensive test report.
```bash
npx playwright test --ui
```
2. **UI mode for interactive testing:**
```bash
npx playwright test --ui
```
This opens a user interface where you can select specific tests to run and inspect the test execution timeline.
UI mode features:
![Playwright UI Mode](https://github.com/user-attachments/assets/6a1ebef0-90eb-4157-8694-f5ee94d03755)
- **Locator picker**: Click the target icon, then click any element to get the exact locator code to use in your test. The code appears in the _Locator_ tab.
- **Step debugging**: Step through your test line-by-line by clicking _Source_ tab
- **Time travel**: In the _Actions_ tab/panel, click any step to see the browser state at that moment
- **Console/Network Tabs**: View logs and API calls at each step
- **Attachments Tab**: View all snapshots with expected and actual images
3. **Running specific tests:**
```bash
npx playwright test widget.spec.ts
```
![Playwright UI Mode](https://github.com/user-attachments/assets/c158c93f-b39a-44c5-a1a1-e0cc975ee9f2)
For CI or headless testing:
```bash
npx playwright test # Run all tests
npx playwright test widget.spec.ts # Run specific test file
```
### Local Development Config
For debugging, you can try adjusting these settings in `playwright.config.ts`:
```typescript
export default defineConfig({
// VERY HELPFUL: Skip screenshot tests locally
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
timeout: 30000, // Longer timeout for breakpoints
use: {
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
video: 'on' // Always record video (CI uses 'retain-on-failure')
},
})
```
## Test Structure
Browser tests in this project follow a specific organization pattern:
- **Fixtures**: Located in `fixtures/` - These provide test setup and utilities
- `ComfyPage.ts` - The main fixture for interacting with ComfyUI
- `ComfyMouse.ts` - Utility for mouse interactions with the canvas
- Components fixtures in `fixtures/components/` - Page object models for UI components
- **Tests**: Located in `tests/` - The actual test specifications
- Organized by functionality (e.g., `widget.spec.ts`, `interaction.spec.ts`)
- Snapshot directories (e.g., `widget.spec.ts-snapshots/`) contain reference screenshots
@@ -86,18 +143,18 @@ When writing new tests, follow these patterns:
```typescript
// Import the test fixture
import { comfyPageFixture as test } from '../fixtures/ComfyPage';
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Feature Name', () => {
// Set up test environment if needed
test.beforeEach(async ({ comfyPage }) => {
// Common setup
});
})
test('should do something specific', async ({ comfyPage }) => {
// Test implementation
});
});
})
})
```
### Leverage Existing Fixtures and Helpers
@@ -119,66 +176,102 @@ Most common testing needs are already addressed by these helpers, which will mak
1. **Focus elements explicitly**:
Canvas-based elements often need explicit focus before interaction:
```typescript
// Click the canvas first to focus it before pressing keys
await comfyPage.canvas.click();
await comfyPage.page.keyboard.press('a');
await comfyPage.canvas.click()
await comfyPage.page.keyboard.press('a')
```
2. **Mark canvas as dirty if needed**:
Some interactions need explicit canvas updates:
```typescript
// After programmatically changing node state, mark canvas dirty
await comfyPage.page.evaluate(() => {
window['app'].graph.setDirtyCanvas(true, true);
});
window['app'].graph.setDirtyCanvas(true, true)
})
```
3. **Use node references over coordinates**:
3. **Use node references over coordinates**:
Node references from `fixtures/utils/litegraphUtils.ts` provide stable ways to interact with nodes:
```typescript
// Prefer this:
const node = await comfyPage.getNodeRefsByType('LoadImage')[0];
await node.click('title');
const node = await comfyPage.getNodeRefsByType('LoadImage')[0]
await node.click('title')
// Over this:
await comfyPage.canvas.click({ position: { x: 100, y: 100 } });
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
```
4. **Wait for canvas to render after UI interactions**:
```typescript
await comfyPage.nextFrame();
await comfyPage.nextFrame()
```
5. **Clean up persistent server state**:
While most state is reset between tests, anything stored on the server persists:
```typescript
// Reset settings that affect other tests (these are stored on server)
await comfyPage.setSetting('Comfy.ColorPalette', 'dark');
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', 'None');
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', 'None')
// Clean up uploaded files if needed
await comfyPage.request.delete(`${comfyPage.url}/api/delete/image.png`);
await comfyPage.request.delete(`${comfyPage.url}/api/delete/image.png`)
```
6. **Prefer functional assertions over screenshots**:
Use screenshots only when visual verification is necessary:
```typescript
// Prefer this:
expect(await node.isPinned()).toBe(true);
expect(await node.getProperty('title')).toBe('Expected Title');
expect(await node.isPinned()).toBe(true)
expect(await node.getProperty('title')).toBe('Expected Title')
// Over this - only use when needed:
await expect(comfyPage.canvas).toHaveScreenshot('state.png');
await expect(comfyPage.canvas).toHaveScreenshot('state.png')
```
7. **Use minimal test workflows**:
When creating test workflows, keep them as minimal as possible:
```typescript
// Include only the components needed for the test
await comfyPage.loadWorkflow('single_ksampler');
await comfyPage.loadWorkflow('single_ksampler')
```
8. **Debug helpers for visual debugging** (remove before committing):
ComfyPage includes temporary debug methods for troubleshooting:
```typescript
test('debug failing interaction', async ({ comfyPage }, testInfo) => {
// Add visual markers to see click positions
await comfyPage.debugAddMarker({ x: 100, y: 200 })
// Attach screenshot with markers to test report
await comfyPage.debugAttachScreenshot(testInfo, 'node-positions', {
element: 'canvas',
markers: [{ position: { x: 100, y: 200 } }]
})
// Show canvas overlay for easier debugging
await comfyPage.debugShowCanvasOverlay()
// Remember to remove debug code before committing!
})
```
Available debug methods:
- `debugAddMarker(position)` - Red circle at position
- `debugAttachScreenshot(testInfo, name)` - Attach to test report
- `debugShowCanvasOverlay()` - Show canvas as overlay
- `debugGetCanvasDataURL()` - Get canvas as base64
## Common Patterns and Utilities
### Page Object Pattern
@@ -192,7 +285,7 @@ test('Can toggle boolean widget', async ({ comfyPage }) => {
const node = (await comfyPage.getFirstNodeRef())!
const widget = await node.getWidget(0)
await widget.click()
});
})
```
### Node References
@@ -232,8 +325,8 @@ Canvas operations use special helpers to ensure proper timing:
```typescript
// Using ComfyMouse for drag and drop
await comfyMouse.dragAndDrop(
{ x: 100, y: 100 }, // From
{ x: 200, y: 200 } // To
{ x: 100, y: 100 }, // From
{ x: 200, y: 200 } // To
)
// Standard ComfyPage helpers
@@ -275,21 +368,52 @@ await expect(node).toBeCollapsed()
- **Screenshots vary**: Ensure your OS and browser match the reference environment (Linux)
- **Async / await**: Race conditions are a very common cause of test flakiness
## Screenshot Expectations
## Screenshot Testing
Due to variations in system font rendering, screenshot expectations are platform-specific. Please note:
- **DO NOT commit local screenshot expectations** to the repository
- **Do not commit local screenshot expectations** to the repository
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment
- While developing, you can generate local screenshots for your tests, but these will differ from CI-generated ones
To set new test expectations for PR:
### Working with Screenshots Locally
1. Write your test with screenshot assertions using `toHaveScreenshot(filename)`
2. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch
3. Add the `New Browser Test Expectation` tag to your pull request
4. The GitHub CI will automatically generate and commit the reference screenshots
Option 1 - Skip screenshot tests (add to `playwright.config.ts`):
This approach ensures consistent screenshot expectations across all PRs and avoids issues with platform-specific rendering.
```typescript
export default defineConfig({
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
})
```
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.
Option 2 - Generate local baselines for comparison:
```bash
npx playwright test --update-snapshots
```
### Getting Test Artifacts from GitHub Actions
When tests fail in CI, you can download screenshots and traces:
1. Go to the failed workflow run in GitHub Actions
2. Scroll to "Artifacts" section at the bottom
3. Download `playwright-report` or `test-results`
4. Extract and open the HTML report locally
5. View actual vs expected screenshots and execution traces
### Creating New Screenshot Baselines
For PRs from `Comfy-Org/ComfyUI_frontend` branches:
1. Write test with `toHaveScreenshot('filename.png')`
2. Create PR and add `New Browser Test Expectation` label
3. CI will generate and commit the Linux baseline screenshots
> **Note:** Fork PRs cannot auto-commit screenshots. A maintainer will need to commit the screenshots manually for you (don't worry, they'll do it).
## Resources
- [Playwright UI Mode](https://playwright.dev/docs/test-ui-mode) - Interactive test debugging
- [Playwright Debugging Guide](https://playwright.dev/docs/debug)
- [act](https://github.com/nektos/act) - Run GitHub Actions locally for CI debugging

View File

@@ -0,0 +1,244 @@
{
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [
627.5973510742188,
423.0972900390625
],
"size": [
144.15234375,
46
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 4,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
347.90441582814213,
417.3822440655296,
120,
60
]
},
"outputNode": {
"id": -20,
"bounding": [
892.5973510742188,
416.0972900390625,
120,
60
]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [
1
],
"pos": {
"0": 447.9044189453125,
"1": 437.3822326660156
}
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [
2
],
"pos": {
"0": 912.5973510742188,
"1": 436.0972900390625
}
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [
554.8743286132812,
100.95539093017578
],
"size": [
270,
262
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"simple",
1
]
},
{
"id": 2,
"type": "VAEEncode",
"pos": [
685.1265869140625,
439.1734619140625
],
"size": [
140,
46
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": null
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.8894351682943402,
"offset": [
58.7671207025881,
137.7124650620126
]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -0,0 +1,412 @@
{
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [
791.59912109375,
386.13336181640625
],
"size": [
210,
202
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {},
"widgets_values": [
"",
""
]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 12,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
481.59912109375,
379.13336181640625,
120,
160
]
},
"outputNode": {
"id": -20,
"bounding": [
1121.59912109375,
379.13336181640625,
128.6640625,
60
]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [
10
],
"pos": {
"0": 581.59912109375,
"1": 399.13336181640625
}
},
{
"id": "736e5a03-0f7f-4e48-93e4-fd66ea6c30f1",
"name": "text_1",
"type": "STRING",
"linkIds": [
11
],
"pos": {
"0": 581.59912109375,
"1": 419.13336181640625
}
},
{
"id": "b62e7a0b-cc7e-4ca5-a4e1-c81607a13f58",
"name": "model",
"type": "MODEL",
"linkIds": [
13
],
"pos": {
"0": 581.59912109375,
"1": 439.13336181640625
}
},
{
"id": "7a2628da-4879-4f82-a7d3-7b1c00db50a5",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [
14
],
"pos": {
"0": 581.59912109375,
"1": 459.13336181640625
}
},
{
"id": "651cf4ad-e8bf-47f6-b181-8f8aeacd6669",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [
15
],
"pos": {
"0": 581.59912109375,
"1": 479.13336181640625
}
},
{
"id": "c41765ea-61ef-4a77-8cc6-74113903078f",
"name": "latent_image",
"type": "LATENT",
"linkIds": [
16
],
"pos": {
"0": 581.59912109375,
"1": 499.13336181640625
}
}
],
"outputs": [
{
"id": "55dd1505-12bd-4cb4-8e75-031a97bb4387",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [
12
],
"pos": {
"0": 1141.59912109375,
"1": 399.13336181640625
}
}
],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
661.59912109375,
314.13336181640625
],
"size": [
400,
200
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 11,
"type": "CLIPTextEncode",
"pos": [
668.755859375,
571.7766723632812
],
"size": [
400,
200
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 11
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
12
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 12,
"type": "KSampler",
"pos": [
671.7379760742188,
1.621593713760376
],
"size": [
270,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 13
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 15
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 16
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"simple",
1
]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 11,
"target_slot": 1,
"type": "STRING"
},
{
"id": 12,
"origin_id": 11,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 2,
"target_id": 12,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 3,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 4,
"target_id": 12,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 5,
"target_id": 12,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.9581355200690549,
"offset": [
184.687451089395,
80.38288288288285
]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -0,0 +1,341 @@
{
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [
400,
300
],
"size": [
210,
168
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
},
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": [
""
]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
481.59912109375,
379.13336181640625,
120,
160
]
},
"outputNode": {
"id": -20,
"bounding": [
1121.59912109375,
379.13336181640625,
120,
40
]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [
10
],
"pos": {
"0": 581.59912109375,
"1": 399.13336181640625
}
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [
11
],
"pos": {
"0": 581.59912109375,
"1": 419.13336181640625
}
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [
12
],
"pos": {
"0": 581.59912109375,
"1": 439.13336181640625
}
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [
13
],
"pos": {
"0": 581.59912109375,
"1": 459.13336181640625
}
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [
14
],
"pos": {
"0": 581.59912109375,
"1": 479.13336181640625
}
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [
15
],
"pos": {
"0": 581.59912109375,
"1": 499.13336181640625
}
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
661.59912109375,
314.13336181640625
],
"size": [
400,
200
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
},
{
"id": 11,
"type": "KSampler",
"pos": [
674.1234741210938,
570.5839233398438
],
"size": [
270,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"simple",
1
]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.9581355200690549,
"offset": [
258.6405769416877,
147.17927927927929
]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -0,0 +1,153 @@
{
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
"revision": 0,
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [
791.59912109375,
386.13336181640625
],
"size": [
140,
26
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
481.59912109375,
379.13336181640625,
120,
60
]
},
"outputNode": {
"id": -20,
"bounding": [
1121.59912109375,
379.13336181640625,
120,
40
]
},
"inputs": [
{
"id": "79e69fca-ad12-499b-8d9b-9f1656b85354",
"name": "clip",
"type": "CLIP",
"linkIds": [
10
],
"pos": {
"0": 581.59912109375,
"1": 399.13336181640625
}
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
661.59912109375,
314.13336181640625
],
"size": [
400,
200
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.24.1",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"ds": {
"scale": 0.9581355200690549,
"offset": [
258.6405769416877,
147.17927927927929
]
}
},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -21,7 +21,7 @@ import {
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import type { Position, Size } from './types'
import { NodeReference } from './utils/litegraphUtils'
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
import TaskHistory from './utils/taskHistory'
dotenv.config()
@@ -168,7 +168,7 @@ export class ComfyPage {
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
}
@@ -563,6 +563,7 @@ export class ComfyPage {
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
@@ -776,11 +777,524 @@ export class ComfyPage {
await this.nextFrame()
}
/**
* Clicks on a litegraph context menu item (uses .litemenu-entry selector).
* Use this for canvas/node context menus, not PrimeVue menus.
*/
async clickLitegraphContextMenuItem(name: string): Promise<void> {
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
await this.nextFrame()
}
/**
* Right-clicks on a subgraph input slot to open the context menu.
* Must be called when inside a subgraph.
*
* This method uses the actual slot positions from the subgraph.inputs array,
* which contain the correct coordinates for each input slot. These positions
* are different from the visual node positions and are specifically where
* the slots are rendered on the input node.
*
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
* If not provided, tries all available input slots until one works.
* @returns Promise that resolves when the context menu appears
*/
async rightClickSubgraphInputSlot(inputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetInputName) => {
const app = window['app']
const currentGraph = app.canvas.graph
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
// Get the input node
const inputNode = currentGraph.inputNode
if (!inputNode) {
throw new Error('No input node found in subgraph')
}
// Get available inputs
const inputs = currentGraph.inputs
if (!inputs || inputs.length === 0) {
throw new Error('No input slots found in subgraph')
}
// Filter to specific input if requested
const inputsToTry = targetInputName
? inputs.filter((inp) => inp.name === targetInputName)
: inputs
if (inputsToTry.length === 0) {
throw new Error(
targetInputName
? `Input slot '${targetInputName}' not found`
: 'No input slots available to try'
)
}
// Try right-clicking on each input slot position until one works
for (const input of inputsToTry) {
if (!input.pos) continue
const testX = input.pos[0]
const testY = input.pos[1]
// Create a right-click event at the input slot position
const rightClickEvent = {
canvasX: testX,
canvasY: testY,
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
// Trigger the input node's right-click handler
if (inputNode.onPointerDown) {
inputNode.onPointerDown(
rightClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if litegraph context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return { success: true, inputName: input.name, x: testX, y: testY }
}
}
return { success: false }
}, inputName)
if (!foundSlot.success) {
throw new Error(
inputName
? `Could not open context menu for input slot '${inputName}'`
: 'Could not find any input slot position to right-click'
)
}
// Wait for the litegraph context menu to be visible
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
}
/**
* Right-clicks on a subgraph output slot to open the context menu.
* Must be called when inside a subgraph.
*
* Similar to rightClickSubgraphInputSlot but for output slots.
*
* @param outputName Optional name of the specific output slot to target.
* If not provided, tries all available output slots until one works.
* @returns Promise that resolves when the context menu appears
*/
async rightClickSubgraphOutputSlot(outputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetOutputName) => {
const app = window['app']
const currentGraph = app.canvas.graph
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
// Get the output node
const outputNode = currentGraph.outputNode
if (!outputNode) {
throw new Error('No output node found in subgraph')
}
// Get available outputs
const outputs = currentGraph.outputs
if (!outputs || outputs.length === 0) {
throw new Error('No output slots found in subgraph')
}
// Filter to specific output if requested
const outputsToTry = targetOutputName
? outputs.filter((out) => out.name === targetOutputName)
: outputs
if (outputsToTry.length === 0) {
throw new Error(
targetOutputName
? `Output slot '${targetOutputName}' not found`
: 'No output slots available to try'
)
}
// Try right-clicking on each output slot position until one works
for (const output of outputsToTry) {
if (!output.pos) continue
const testX = output.pos[0]
const testY = output.pos[1]
// Create a right-click event at the output slot position
const rightClickEvent = {
canvasX: testX,
canvasY: testY,
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
// Trigger the output node's right-click handler
if (outputNode.onPointerDown) {
outputNode.onPointerDown(
rightClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
}
// Wait briefly for menu to appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Check if litegraph context menu appeared
const menuExists = document.querySelector('.litemenu-entry')
if (menuExists) {
return { success: true, outputName: output.name, x: testX, y: testY }
}
}
return { success: false }
}, outputName)
if (!foundSlot.success) {
throw new Error(
outputName
? `Could not open context menu for output slot '${outputName}'`
: 'Could not find any output slot position to right-click'
)
}
// Wait for the litegraph context menu to be visible
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
}
/**
* Get a reference to a subgraph input slot
*/
async getSubgraphInputSlot(
slotName?: string
): Promise<SubgraphSlotReference> {
return new SubgraphSlotReference('input', slotName || '', this)
}
/**
* Get a reference to a subgraph output slot
*/
async getSubgraphOutputSlot(
slotName?: string
): Promise<SubgraphSlotReference> {
return new SubgraphSlotReference('output', slotName || '', this)
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.
*/
async connectToSubgraphInput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetInputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = await this.getSubgraphInputSlot(targetInputName)
const targetPosition = targetInputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition)
await this.nextFrame()
}
/**
* Connect a subgraph input to a regular node input.
* This creates a new input slot on the subgraph if sourceInputName is not provided.
*/
async connectFromSubgraphInput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceInputName?: string
): Promise<void> {
const sourceSlot = await this.getSubgraphInputSlot(sourceInputName)
const targetSlot = await targetNode.getInput(targetSlotIndex)
const sourcePosition = sourceInputName
? await sourceSlot.getPosition() // Connect from existing slot
: await sourceSlot.getOpenSlotPosition() // Create new slot
const targetPosition = await targetSlot.getPosition()
// Debug: Log the positions we're trying to use
console.log('Drag positions:', {
source: sourcePosition,
target: targetPosition
})
await this.dragAndDrop(sourcePosition, targetPosition)
await this.nextFrame()
}
/**
* Connect a regular node output to a subgraph output.
* This creates a new output slot on the subgraph if targetOutputName is not provided.
*/
async connectToSubgraphOutput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetOutputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = await this.getSubgraphOutputSlot(targetOutputName)
const targetPosition = targetOutputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition)
await this.nextFrame()
}
/**
* Connect a subgraph output to a regular node input.
* This creates a new output slot on the subgraph if sourceOutputName is not provided.
*/
async connectFromSubgraphOutput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceOutputName?: string
): Promise<void> {
const sourceSlot = await this.getSubgraphOutputSlot(sourceOutputName)
const targetSlot = await targetNode.getInput(targetSlotIndex)
const sourcePosition = sourceOutputName
? await sourceSlot.getPosition() // Connect from existing slot
: await sourceSlot.getOpenSlotPosition() // Create new slot
await this.dragAndDrop(sourcePosition, await targetSlot.getPosition())
await this.nextFrame()
}
/**
* Add a visual marker at a position for debugging
*/
async debugAddMarker(
position: Position,
id: string = 'debug-marker'
): Promise<void> {
await this.page.evaluate(
([pos, markerId]) => {
// Remove existing marker if present
const existing = document.getElementById(markerId)
if (existing) existing.remove()
// Create marker
const marker = document.createElement('div')
marker.id = markerId
marker.style.position = 'fixed'
marker.style.left = `${pos.x - 10}px`
marker.style.top = `${pos.y - 10}px`
marker.style.width = '20px'
marker.style.height = '20px'
marker.style.border = '2px solid red'
marker.style.borderRadius = '50%'
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
marker.style.pointerEvents = 'none'
marker.style.zIndex = '10000'
document.body.appendChild(marker)
},
[position, id] as const
)
}
/**
* Remove debug markers
*/
async debugRemoveMarkers(): Promise<void> {
await this.page.evaluate(() => {
document
.querySelectorAll('[id^="debug-marker"]')
.forEach((el) => el.remove())
})
}
/**
* Take a screenshot and attach it to the test report for debugging
* This is a convenience method that combines screenshot capture and test attachment
*
* @param testInfo The Playwright TestInfo object (from test parameters)
* @param name Name for the attachment
* @param options Optional screenshot options (defaults to page screenshot)
*/
async debugAttachScreenshot(
testInfo: any,
name: string,
options?: {
fullPage?: boolean
element?: 'canvas' | 'page'
markers?: Array<{ position: Position; id?: string }>
}
): Promise<void> {
// Add markers if requested
if (options?.markers) {
for (const marker of options.markers) {
await this.debugAddMarker(marker.position, marker.id)
}
}
// Take screenshot - default to page if not specified
let screenshot: Buffer
const targetElement = options?.element || 'page'
if (targetElement === 'canvas') {
screenshot = await this.canvas.screenshot()
} else if (options?.fullPage) {
screenshot = await this.page.screenshot({ fullPage: true })
} else {
screenshot = await this.page.screenshot()
}
// Attach to test report
await testInfo.attach(name, {
body: screenshot,
contentType: 'image/png'
})
// Clean up markers if we added any
if (options?.markers) {
await this.debugRemoveMarkers()
}
}
async doubleClickCanvas() {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
}
/**
* Capture the canvas as a PNG and save it for debugging
*/
async debugSaveCanvasScreenshot(filename: string): Promise<void> {
await this.page.evaluate(async (filename) => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
// Convert canvas to blob
return new Promise<void>((resolve) => {
canvas.toBlob(async (blob) => {
if (!blob) {
throw new Error('Failed to create blob from canvas')
}
// Create a download link and trigger it
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
resolve()
}, 'image/png')
})
}, filename)
// Wait a bit for the download to process
await this.page.waitForTimeout(500)
}
/**
* Capture canvas as base64 data URL for inspection
*/
async debugGetCanvasDataURL(): Promise<string> {
return await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return canvas.toDataURL('image/png')
})
}
/**
* Create an overlay div with the canvas image for easier Playwright screenshot
*/
async debugShowCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
// Remove existing overlay if present
const existingOverlay = document.getElementById('debug-canvas-overlay')
if (existingOverlay) {
existingOverlay.remove()
}
// Create overlay div
const overlay = document.createElement('div')
overlay.id = 'debug-canvas-overlay'
overlay.style.position = 'fixed'
overlay.style.top = '0'
overlay.style.left = '0'
overlay.style.zIndex = '9999'
overlay.style.backgroundColor = 'white'
overlay.style.padding = '10px'
overlay.style.border = '2px solid red'
// Create image from canvas
const img = document.createElement('img')
img.src = canvas.toDataURL('image/png')
img.style.maxWidth = '800px'
img.style.maxHeight = '600px'
overlay.appendChild(img)
document.body.appendChild(overlay)
})
}
/**
* Remove the debug canvas overlay
*/
async debugHideCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const overlay = document.getElementById('debug-canvas-overlay')
if (overlay) {
overlay.remove()
}
})
}
async clickEmptyLatentNode() {
await this.canvas.click({
position: {

View File

@@ -1,15 +1,19 @@
import { Page } from '@playwright/test'
import { ComfyPage } from '../ComfyPage'
export class SettingDialog {
constructor(public readonly page: Page) {}
constructor(
public readonly page: Page,
public readonly comfyPage: ComfyPage
) {}
get root() {
return this.page.locator('div.settings-container')
}
async open() {
const button = this.page.locator('button.comfy-settings-btn:visible')
await button.click()
await this.comfyPage.executeCommand('Comfy.ShowSettingsDialog')
await this.page.waitForSelector('div.settings-container')
}

View File

@@ -15,10 +15,6 @@ export class Topbar {
.innerText()
}
async openSubmenuMobile() {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}
getMenuItem(itemLabel: string): Locator {
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
@@ -68,31 +64,41 @@ export class Topbar {
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
}
async openTopbarMenu() {
await this.page.locator('.comfyui-logo-wrapper').click()
const menu = this.page.locator('.comfy-command-menu')
await menu.waitFor({ state: 'visible' })
return menu
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
}
const menu = await this.openTopbarMenu()
const tabName = path[0]
const topLevelMenu = this.page.locator(
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
const topLevelMenuItem = this.page.locator(
`.p-menubar-item-label:text-is("${tabName}")`
)
const topLevelMenu = menu
.locator('.p-tieredmenu-item')
.filter({ has: topLevelMenuItem })
await topLevelMenu.waitFor({ state: 'visible' })
await topLevelMenu.click()
await topLevelMenu.hover()
let currentMenu = topLevelMenu
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = this.page
const menuItem = currentMenu
.locator(
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
)
.first()
await menuItem.waitFor({ state: 'visible' })
await menuItem.hover()
if (i === path.length - 1) {
await menuItem.click()
}
currentMenu = menuItem
}
await currentMenu.click()
}
}

View File

@@ -12,6 +12,128 @@ export const getMiddlePoint = (pos1: Position, pos2: Position) => {
}
}
export class SubgraphSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly slotName: string,
readonly comfyPage: ComfyPage
) {}
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(
([type, slotName]) => {
const currentGraph = window['app'].canvas.graph
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
const slots =
type === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!slots || slots.length === 0) {
throw new Error(`No ${type} slots found in subgraph`)
}
// Find the specific slot or use the first one if no name specified
const slot = slotName
? slots.find((s) => s.name === slotName)
: slots[0]
if (!slot) {
throw new Error(`${type} slot '${slotName}' not found`)
}
if (!slot.pos) {
throw new Error(`${type} slot '${slotName}' has no position`)
}
// Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
slot.pos[0],
slot.pos[1]
])
return canvasPos
},
[this.type, this.slotName] as const
)
return {
x: pos[0],
y: pos[1]
}
}
async getOpenSlotPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(
([type]) => {
const currentGraph = window['app'].canvas.graph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}
const node =
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
const slots =
type === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!node) {
throw new Error(`No ${type} node found in subgraph`)
}
// Calculate position for next available slot
// const nextSlotIndex = slots?.length || 0
// const slotHeight = 20
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
// Find last slot position
const lastSlot = slots.at(-1)
let slotX: number
let slotY: number
if (lastSlot) {
// If there are existing slots, position the new one below the last one
const gapHeight = 20
slotX = lastSlot.pos[0]
slotY = lastSlot.pos[1] + gapHeight
} else {
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
if (currentGraph.slotAnchorX !== undefined) {
// The actual slot X position seems to be slotAnchorX - 10
slotX = currentGraph.slotAnchorX - 10
} else {
// Fallback: calculate from node edge
slotX =
type === 'input'
? node.pos[0] + node.size[0] - 10 // Right edge for input node
: node.pos[0] + 10 // Left edge for output node
}
// For Y position when no slots exist, use middle of node
slotY = node.pos[1] + node.size[1] / 2
}
// Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
slotX,
slotY
])
return canvasPos
},
[this.type] as const
)
return {
x: pos[0],
y: pos[1]
}
}
}
export class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
@@ -21,11 +143,27 @@ export class NodeSlotReference {
async getPosition() {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
// Use canvas.graph to get the current graph (works in both main graph and subgraphs)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
return window['app'].canvas.ds.convertOffsetToCanvas(
node.getConnectionPos(type === 'input', index)
const rawPos = node.getConnectionPos(type === 'input', index)
const convertedPos =
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
// Debug logging - convert Float32Arrays to regular arrays for visibility
console.log(
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
{
nodePos: [node.pos[0], node.pos[1]],
nodeSize: [node.size[0], node.size[1]],
rawConnectionPos: [rawPos[0], rawPos[1]],
convertedPos: [convertedPos[0], convertedPos[1]],
currentGraphType: window['app'].canvas.graph.constructor.name
}
)
return convertedPos
},
[this.type, this.node.id, this.index] as const
)
@@ -37,7 +175,7 @@ export class NodeSlotReference {
async getLinkCount() {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
@@ -50,7 +188,7 @@ export class NodeSlotReference {
async removeLinks() {
await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
@@ -75,7 +213,7 @@ export class NodeWidgetReference {
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
@@ -134,7 +272,7 @@ export class NodeWidgetReference {
const pos = await this.getPosition()
const canvas = this.node.comfyPage.canvas
const canvasPos = (await canvas.boundingBox())!
this.node.comfyPage.dragAndDrop(
await this.node.comfyPage.dragAndDrop(
{
x: canvasPos.x + pos.x,
y: canvasPos.y + pos.y
@@ -166,7 +304,7 @@ export class NodeReference {
) {}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
return !!node
}, this.id)
}
@@ -185,7 +323,7 @@ export class NodeReference {
async getBounding(): Promise<Position & Size> {
const [x, y, width, height]: [number, number, number, number] =
await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node.getBounding()
}, this.id)
@@ -218,7 +356,7 @@ export class NodeReference {
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
const node = window['app'].graph.getNodeById(id)
const node = window['app'].canvas.graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node[prop]
},
@@ -259,7 +397,8 @@ export class NodeReference {
await this.comfyPage.canvas.click({
...options,
position: clickPos
position: clickPos,
force: true
})
await this.comfyPage.nextFrame()
if (moveMouseToEmptyArea) {
@@ -319,6 +458,18 @@ export class NodeReference {
}
return nodes[0]
}
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')
await this.comfyPage.nextFrame()
await this.comfyPage.page.waitForTimeout(256)
const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph')
if (nodes.length !== 1) {
throw new Error(
`Did not find single subgraph node (found=${nodes.length})`
)
}
return nodes[0]
}
async manageGroupNode() {
await this.clickContextMenuOption('Manage Group Node')
await this.comfyPage.nextFrame()
@@ -327,4 +478,58 @@ export class NodeReference {
this.comfyPage.page.locator('.comfy-group-manage')
)
}
async navigateIntoSubgraph() {
const titleHeight = await this.comfyPage.page.evaluate(() => {
return window['LiteGraph']['NODE_TITLE_HEIGHT']
})
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
// Try multiple positions to avoid DOM widget interference
const clickPositions = [
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
]
let isInSubgraph = false
let attempts = 0
const maxAttempts = 3
while (!isInSubgraph && attempts < maxAttempts) {
attempts++
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
position: { x: 50, y: 50 },
force: true
})
await this.comfyPage.nextFrame()
// Double-click to enter subgraph
await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame()
await this.comfyPage.page.waitForTimeout(500)
// Check if we successfully entered the subgraph
isInSubgraph = await this.comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph?.constructor?.name === 'Subgraph'
})
if (isInSubgraph) break
}
if (!isInSubgraph && attempts < maxAttempts) {
await this.comfyPage.page.waitForTimeout(500)
}
}
if (!isInSubgraph) {
throw new Error(
'Failed to navigate into subgraph after ' + attempts + ' attempts'
)
}
}
}

View File

@@ -47,4 +47,42 @@ test.describe('DOM Widget', () => {
const finalCount = await comfyPage.getDOMWidgetCount()
expect(finalCount).toBe(initialCount + 1)
})
test('should reposition when layout changes', async ({ comfyPage }) => {
// --- setup ---
const textareaWidget = comfyPage.page
.locator('.comfy-multiline-input')
.first()
await expect(textareaWidget).toBeVisible()
await comfyPage.setSetting('Comfy.Sidebar.Size', 'small')
await comfyPage.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.nextFrame()
let oldPos: [number, number]
const checkBboxChange = async () => {
const boudningBox = (await textareaWidget.boundingBox())!
expect(boudningBox).not.toBeNull()
const position: [number, number] = [boudningBox.x, boudningBox.y]
expect(position).not.toEqual(oldPos)
oldPos = position
}
await checkBboxChange()
// --- test ---
await comfyPage.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.nextFrame()
await checkBboxChange()
await comfyPage.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.nextFrame()
await checkBboxChange()
await comfyPage.setSetting('Comfy.UseNewMenu', 'Bottom')
await comfyPage.nextFrame()
await checkBboxChange()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -264,10 +264,15 @@ test.describe('Group Node', () => {
test('Copies and pastes group node after clearing workflow', async ({
comfyPage
}) => {
// Set setting
await comfyPage.setSetting('Comfy.ConfirmClear', false)
// Clear workflow
await comfyPage.menu.topbar.triggerTopbarCommand([
'Edit',
'Clear Workflow'
])
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})

View File

@@ -768,8 +768,14 @@ test.describe('Viewport settings', () => {
comfyMouse
}) => {
// Screenshot the canvas element
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await toggleButton.click()
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
await comfyPage.nextFrame()
const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64')
// Save workflow as a new file, then zoom out before screen shot
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
@@ -777,7 +783,12 @@ test.describe('Viewport settings', () => {
for (let i = 0; i < 4; i++) {
await comfyMouse.wheel(0, 60)
}
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
await comfyPage.nextFrame()
const screenshotB = (await comfyPage.canvas.screenshot()).toString('base64')
// Ensure that the screenshots are different due to zoom level
expect(screenshotB).not.toBe(screenshotA)
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
@@ -785,11 +796,269 @@ test.describe('Viewport settings', () => {
// Go back to Workflow A
await tabA.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotA
)
// And back to Workflow B
await tabB.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
screenshotB
)
})
})
test.describe('Canvas Navigation', () => {
test.describe('Legacy Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
})
test('Left-click drag in empty area should pan canvas', async ({
comfyPage
}) => {
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-left-drag-pan.png'
)
})
test('Middle-click drag should pan canvas', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(50, 50)
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-middle-drag-pan.png'
)
})
test('Mouse wheel should zoom in/out', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-wheel-zoom-in.png'
)
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-wheel-zoom-out.png'
)
})
test('Left-click on node should not pan canvas', async ({ comfyPage }) => {
await comfyPage.clickTextEncodeNode1()
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedCount).toBe(1)
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-click-node-select.png'
)
})
})
test.describe('Standard Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'standard')
})
test('Left-click drag in empty area should select nodes', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
await comfyPage.dragAndDrop(
{
x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset,
y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset
},
{
x: Math.max(clipNode1Pos.x, clipNode2Pos.x) + offset,
y: Math.max(clipNode1Pos.y, clipNode2Pos.y) + offset
}
)
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedCount).toBe(clipNodes.length)
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-left-drag-select.png'
)
})
test('Middle-click drag should pan canvas', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(50, 50)
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-middle-drag-pan.png'
)
})
test('Ctrl + mouse wheel should zoom in/out', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-ctrl-wheel-zoom-in.png'
)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-ctrl-wheel-zoom-out.png'
)
})
test('Left-click on node should select node (not start selection box)', async ({
comfyPage
}) => {
await comfyPage.clickTextEncodeNode1()
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedCount).toBe(1)
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-click-node-select.png'
)
})
test('Space + left-click drag should pan canvas', async ({ comfyPage }) => {
// Click canvas to focus it
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.down('Space')
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await comfyPage.page.keyboard.up('Space')
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-space-drag-pan.png'
)
})
test('Space key overrides default left-click behavior', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const offset = 64
await comfyPage.dragAndDrop(
{
x: clipNode1Pos.x - offset,
y: clipNode1Pos.y - offset
},
{
x: clipNode1Pos.x + offset,
y: clipNode1Pos.y + offset
}
)
const selectedCountAfterDrag =
await comfyPage.getSelectedGraphNodesCount()
expect(selectedCountAfterDrag).toBeGreaterThan(0)
await comfyPage.clickEmptySpace()
const selectedCountAfterClear =
await comfyPage.getSelectedGraphNodesCount()
expect(selectedCountAfterClear).toBe(0)
await comfyPage.page.keyboard.down('Space')
await comfyPage.dragAndDrop(
{
x: clipNode1Pos.x - offset,
y: clipNode1Pos.y - offset
},
{
x: clipNode1Pos.x + offset,
y: clipNode1Pos.y + offset
}
)
await comfyPage.page.keyboard.up('Space')
const selectedCountAfterSpaceDrag =
await comfyPage.getSelectedGraphNodesCount()
expect(selectedCountAfterSpaceDrag).toBe(0)
})
})
test('Shift + mouse wheel should pan canvas horizontally', async ({
comfyPage
}) => {
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-shift-wheel-pan-right.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, -240)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-shift-wheel-pan-left.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'standard-shift-wheel-pan-center.png'
)
})
test.describe('Edge Cases', () => {
test('Multiple modifier keys work correctly in legacy mode', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
await comfyPage.page.keyboard.up('Shift')
await comfyPage.page.keyboard.up('Alt')
await expect(comfyPage.canvas).toHaveScreenshot(
'legacy-alt-shift-drag.png'
)
})
test('Cursor changes appropriately in different modes', async ({
comfyPage
}) => {
const getCursorStyle = async () => {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'
)
})
}
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
await comfyPage.page.mouse.move(50, 50)
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')
await comfyPage.page.mouse.up()
})
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -15,7 +15,8 @@ test.describe('Load Workflow in Media', () => {
'workflow.mp4',
'workflow.mov',
'workflow.m4v',
'workflow.svg'
'workflow.svg',
'workflow.avif'
]
fileNames.forEach(async (fileName) => {
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -63,7 +63,7 @@ test.describe('Menu', () => {
test('@mobile Items fully visible on mobile screen width', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.openSubmenuMobile()
await comfyPage.menu.topbar.openTopbarMenu()
const topLevelMenuItem = comfyPage.page
.locator('a.p-menubar-item-link')
.first()
@@ -74,8 +74,9 @@ test.describe('Menu', () => {
})
test('Displays keybinding next to item', async ({ comfyPage }) => {
await comfyPage.menu.topbar.openTopbarMenu()
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('Workflow')
await workflowMenuItem.click()
await workflowMenuItem.hover()
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'
})

View File

@@ -0,0 +1,77 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Minimap', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.loadWorkflow('default')
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].canvas
)
})
test('Validate minimap is visible by default', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
const minimapCanvas = minimapContainer.locator('.minimap-canvas')
await expect(minimapCanvas).toBeVisible()
const minimapViewport = minimapContainer.locator('.minimap-viewport')
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'absolute')
await expect(minimapContainer).toHaveCSS('z-index', '1000')
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(toggleButton).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapContainer).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).not.toBeVisible()
await expect(toggleButton).not.toHaveClass(/minimap-active/)
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
await expect(toggleButton).toHaveClass(/minimap-active/)
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
await expect(minimapContainer).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(minimapContainer).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
})
})

View File

@@ -27,6 +27,21 @@ test.describe('Node search box', () => {
await expect(comfyPage.searchBox.input).toHaveCount(1)
})
test('New user (1.24.1+) gets search box by default on link release', async ({
comfyPage
}) => {
// Start fresh to test new user behavior
await comfyPage.setup({ clearStorage: true })
// Simulate new user with 1.24.1+ installed version
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
// Don't set LinkRelease settings explicitly to test versioned defaults
await comfyPage.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await expect(comfyPage.searchBox.input).toBeVisible()
})
test('Can add node', async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
@@ -172,10 +187,10 @@ test.describe('Node search box', () => {
await comfyPage.page.mouse.click(panelBounds!.x - 10, panelBounds!.y - 10)
// Verify the filter selection panel is hidden
expect(panel.header).not.toBeVisible()
await expect(panel.header).not.toBeVisible()
// Verify the node search dialog is still visible
expect(comfyPage.searchBox.input).toBeVisible()
await expect(comfyPage.searchBox.input).toBeVisible()
})
test('Can add multiple filters', async ({ comfyPage }) => {
@@ -264,4 +279,38 @@ test.describe('Release context menu', () => {
'link-context-menu-search.png'
)
})
test('Existing user (pre-1.24.1) gets context menu by default on link release', async ({
comfyPage
}) => {
// Start fresh to test existing user behavior
await comfyPage.setup({ clearStorage: true })
// Simulate existing user with pre-1.24.1 version
await comfyPage.setSetting('Comfy.InstalledVersion', '1.23.0')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
// Don't set LinkRelease settings explicitly to test versioned defaults
await comfyPage.disconnectEdge()
// Context menu should appear, search box should not
await expect(comfyPage.searchBox.input).toHaveCount(0)
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
})
test('Explicit setting overrides versioned defaults', async ({
comfyPage
}) => {
// Start fresh and simulate new user who should get search box by default
await comfyPage.setup({ clearStorage: true })
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
// But explicitly set to context menu (overriding versioned default)
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'context menu')
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.disconnectEdge()
// Context menu should appear due to explicit setting, not search box
await expect(comfyPage.searchBox.input).toHaveCount(0)
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
})
})

View File

@@ -0,0 +1,469 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
const TEST_WIDGET_CONTENT = 'Test content that should persist'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
promptDialog: '.graphdialog input',
nodeSearchContainer: '.node-search-container',
domWidget: '.comfy-multiline-input'
} as const
test.describe('Subgraph Operations', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// Helper to get subgraph slot count
async function getSubgraphSlotCount(
comfyPage: typeof test.prototype.comfyPage,
type: 'inputs' | 'outputs'
): Promise<number> {
return await comfyPage.page.evaluate((slotType) => {
return window['app'].canvas.graph[slotType]?.length || 0
}, type)
}
// Helper to get current graph node count
async function getGraphNodeCount(
comfyPage: typeof test.prototype.comfyPage
): Promise<number> {
return await comfyPage.page.evaluate(() => {
return window['app'].canvas.graph.nodes?.length || 0
})
}
// Helper to verify we're in a subgraph
async function isInSubgraph(
comfyPage: typeof test.prototype.comfyPage
): Promise<boolean> {
return await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph?.constructor?.name === 'Subgraph'
})
}
test.describe('I/O Slot Management', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
await comfyPage.connectFromSubgraphInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(finalCount).toBe(initialCount + 1)
})
test('Can add output slots to subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
await comfyPage.connectToSubgraphOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(finalCount).toBe(initialCount + 1)
})
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.rightClickSubgraphInputSlot()
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(finalCount).toBe(initialCount - 1)
})
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.rightClickSubgraphOutputSlot()
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(finalCount).toBe(initialCount - 1)
})
test('Can rename I/O slots', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
})
test.describe('Subgraph Creation and Deletion', () => {
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
const initialNodeCount = await getGraphNodeCount(comfyPage)
await comfyPage.ctrlA()
await comfyPage.nextFrame()
const node = await comfyPage.getNodeRefById('5')
await node.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
expect(subgraphNodes.length).toBe(1)
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(1)
})
test('Can delete subgraph node', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
expect(await subgraphNode.exists()).toBe(true)
const initialNodeCount = await getGraphNodeCount(comfyPage)
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(initialNodeCount - 1)
const deletedNode = await comfyPage.getNodeRefById('2')
expect(await deletedNode.exists()).toBe(false)
})
})
test.describe('Operations Inside Subgraphs', () => {
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialNodeCount = await getGraphNodeCount(comfyPage)
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodes = window['app'].canvas.graph.nodes
return nodes?.[0]?.id || null
})
expect(nodesInSubgraph).not.toBeNull()
const nodeToClone = await comfyPage.getNodeRefById(
String(nodesInSubgraph)
)
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+c')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.nextFrame()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(initialNodeCount + 1)
})
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Add a node
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.nextFrame()
// Get initial node count
const initialCount = await getGraphNodeCount(comfyPage)
// Undo
await comfyPage.ctrlZ()
await comfyPage.nextFrame()
const afterUndoCount = await getGraphNodeCount(comfyPage)
expect(afterUndoCount).toBe(initialCount - 1)
// Redo
await comfyPage.ctrlY()
await comfyPage.nextFrame()
const afterRedoCount = await getGraphNodeCount(comfyPage)
expect(afterRedoCount).toBe(initialCount)
})
})
test.describe('Subgraph Navigation and UI', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Breadcrumb updates when subgraph node title is changed', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
// Navigate into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 20000
})
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back and edit title
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10
},
delay: 5
})
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget visibility persists through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
await comfyPage.nextFrame()
// Verify promoted widget is visible in parent graph
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.navigateIntoSubgraph()
// Verify widget is visible in subgraph
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
// Navigate back
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Verify widget is still visible
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
})
test('DOM widget content is preserved through navigation', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
await textarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
const initialCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(initialCount).toBe(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(0)
})
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
// Enable new menu for breadcrumb navigation
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
const workflowName = 'subgraph-with-promoted-text-widget'
await comfyPage.loadWorkflow(workflowName)
const textareaCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(textareaCount).toBe(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
// Navigate into subgraph (method now handles retries internally)
await subgraphNode.navigateIntoSubgraph()
await comfyPage.rightClickSubgraphInputSlot('text')
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
await comfyPage.page.waitForTimeout(200)
// Wait for breadcrumb to be visible
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 5000
})
// Click breadcrumb to navigate back to parent graph
const homeBreadcrumb = comfyPage.page.getByRole('link', {
// In the subgraph navigation breadcrumbs, the home/top level
// breadcrumb is just the workflow name
name: workflowName
})
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(300)
// Check that the subgraph node has no widgets after removing the text slot
const widgetCount = await comfyPage.page.evaluate(() => {
return window['app'].canvas.graph.nodes[0].widgets?.length || 0
})
expect(widgetCount).toBe(0)
})
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraph-with-multiple-promoted-widgets')
const parentCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(parentCount).toBeGreaterThan(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(subgraphCount).toBe(parentCount)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(parentCount)
})
})
})

View File

@@ -1,82 +0,0 @@
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,117 @@
import { expect } from '@playwright/test'
import { SystemStats } from '../../src/schemas/apiSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Version Mismatch Warnings', () => {
const ALWAYS_AHEAD_OF_INSTALLED_VERSION = '100.100.100'
const ALWAYS_BEHIND_INSTALLED_VERSION = '0.0.0'
const createMockSystemStatsRes = (
requiredFrontendVersion: string
): SystemStats => {
return {
system: {
os: 'posix',
ram_total: 67235385344,
ram_free: 13464207360,
comfyui_version: '0.3.46',
required_frontend_version: requiredFrontendVersion,
python_version: '3.12.3 (main, Jun 18 2025, 17:59:45) [GCC 13.3.0]',
pytorch_version: '2.6.0+cu124',
embedded_python: false,
argv: ['main.py']
},
devices: [
{
name: 'cuda:0 NVIDIA GeForce RTX 4070 : cudaMallocAsync',
type: 'cuda',
index: 0,
vram_total: 12557156352,
vram_free: 2439249920,
torch_vram_total: 0,
torch_vram_free: 0
}
]
}
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('should show version mismatch warnings when installed version lower than required', async ({
comfyPage
}) => {
// Mock system_stats route to indicate that the installed version is always ahead of the required version
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
createMockSystemStatsRes(ALWAYS_AHEAD_OF_INSTALLED_VERSION)
)
})
})
await comfyPage.setup()
// Expect a warning toast to be shown
await expect(
comfyPage.page.getByText('Version Compatibility Warning')
).toBeVisible()
})
test('should not show version mismatch warnings when installed version is ahead of required', async ({
comfyPage
}) => {
// Mock system_stats route to indicate that the installed version is always ahead of the required version
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
createMockSystemStatsRes(ALWAYS_BEHIND_INSTALLED_VERSION)
)
})
})
await comfyPage.setup()
// Expect no warning toast to be shown
await expect(
comfyPage.page.getByText('Version Compatibility Warning')
).not.toBeVisible()
})
test('should persist dismissed state across sessions', async ({
comfyPage
}) => {
// Mock system_stats route to indicate that the installed version is always ahead of the required version
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
createMockSystemStatsRes(ALWAYS_AHEAD_OF_INSTALLED_VERSION)
)
})
})
await comfyPage.setup()
// Locate the warning toast and dismiss it
const warningToast = comfyPage.page
.locator('div')
.filter({ hasText: 'Version Compatibility' })
.nth(3)
await warningToast.waitFor({ state: 'visible' })
const dismissButton = warningToast.getByRole('button', { name: 'Close' })
await dismissButton.click()
// Reload the page, keeping local storage
await comfyPage.setup({ clearStorage: false })
// The same warning from same versions should not be shown to the user again
await expect(
comfyPage.page.getByText('Version Compatibility Warning')
).not.toBeVisible()
})
})

View File

@@ -48,7 +48,7 @@ test.describe('Combo text widget', () => {
await comfyPage.page.keyboard.press('r')
// Wait for nodes' widgets to be updated
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(500)
const refreshedComboValues = await getComboValues()
expect(refreshedComboValues).not.toEqual(initialComboValues)

View File

@@ -1,72 +0,0 @@
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()
}
})

1551
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.24.1",
"version": "1.25.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -40,12 +40,12 @@
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@types/semver": "^7.7.0",
"@types/three": "^0.169.0",
"@vitejs/plugin-vue": "^5.1.4",
"@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",
@@ -63,8 +63,8 @@
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"unplugin-icons": "^0.22.0",
"unplugin-vue-components": "^0.28.0",
"vite": "^5.4.19",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-html": "^3.2.2",
@@ -78,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.9",
"@comfyorg/litegraph": "^0.16.20",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -97,6 +97,8 @@
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
@@ -106,6 +108,7 @@
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",

View File

@@ -15,12 +15,14 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
@@ -47,5 +49,9 @@ onMounted(() => {
if (isElectron()) {
document.addEventListener('contextmenu', showContextMenu)
}
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()
})
</script>

184
src/assets/icons/README.md Normal file
View File

@@ -0,0 +1,184 @@
# ComfyUI Custom Icons Guide
This guide explains how to add and use custom SVG icons in the ComfyUI frontend.
## Overview
ComfyUI uses a hybrid icon system that supports:
- **PrimeIcons** - Legacy icon library (CSS classes like `pi pi-plus`)
- **Iconify** - Modern icon system with 200,000+ icons
- **Custom Icons** - Your own SVG icons
Custom icons are powered by [unplugin-icons](https://github.com/unplugin/unplugin-icons) and integrate seamlessly with Vue's component system.
## Quick Start
### 1. Add Your SVG Icon
Place your SVG file in the `custom/` directory:
```
src/assets/icons/custom/
└── your-icon.svg
```
### 2. Use in Components
```vue
<template>
<!-- Use as a Vue component -->
<i-comfy:your-icon />
<!-- In a PrimeVue button -->
<Button>
<template #icon>
<i-comfy:your-icon />
</template>
</Button>
</template>
```
## SVG Requirements
### File Naming
- Use kebab-case: `workflow-icon.svg`, `node-tree.svg`
- Avoid special characters and spaces
- The filename becomes the icon name
### SVG Format
```xml
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="..." />
</svg>
```
**Important:**
- Use `viewBox` for proper scaling (24x24 is standard)
- Don't include `width` or `height` attributes
- Use `currentColor` for theme-aware icons
- Keep SVGs optimized and simple
### Color Theming
For icons that adapt to the current theme, use `currentColor`:
```xml
<!-- ✅ Good: Uses currentColor -->
<svg viewBox="0 0 24 24">
<path stroke="currentColor" fill="none" d="..." />
</svg>
<!-- ❌ Bad: Hardcoded colors -->
<svg viewBox="0 0 24 24">
<path stroke="white" fill="black" d="..." />
</svg>
```
## Usage Examples
### Basic Icon
```vue
<i-comfy:workflow />
```
### With Classes
```vue
<i-comfy:workflow class="text-2xl text-blue-500" />
```
### In Buttons
```vue
<Button severity="secondary" text>
<template #icon>
<i-comfy:workflow />
</template>
</Button>
```
### Conditional Icons
```vue
<template #icon>
<i-comfy:workflow v-if="isWorkflow" />
<i-comfy:node v-else />
</template>
```
## Technical Details
### How It Works
1. **unplugin-icons** automatically discovers SVG files in `custom/`
2. During build, SVGs are converted to Vue components
3. Components are tree-shaken - only used icons are bundled
4. The `i-` prefix and `comfy:` namespace identify custom icons
### Configuration
The icon system is configured in `vite.config.mts`:
```typescript
Icons({
compiler: 'vue3',
customCollections: {
'comfy': FileSystemIconLoader('src/assets/icons/custom'),
}
})
```
### TypeScript Support
Icons are automatically typed. If TypeScript doesn't recognize a new icon:
1. Restart your dev server
2. Check that the SVG file is valid
3. Ensure the filename follows kebab-case convention
## Troubleshooting
### Icon Not Showing
1. **Check filename**: Must be kebab-case without special characters
2. **Restart dev server**: Required after adding new icons
3. **Verify SVG**: Ensure it's valid SVG syntax
4. **Check console**: Look for Vue component resolution errors
### Icon Wrong Color
- Replace hardcoded colors with `currentColor`
- Use `stroke="currentColor"` for outlines
- Use `fill="currentColor"` for filled shapes
### Icon Wrong Size
- Remove `width` and `height` from SVG
- Ensure `viewBox` is present
- Use CSS classes for sizing: `class="w-6 h-6"`
## Best Practices
1. **Optimize SVGs**: Use tools like [SVGO](https://jakearchibald.github.io/svgomg/) to minimize file size
2. **Consistent viewBox**: Stick to 24x24 or 16x16 for consistency
3. **Semantic names**: Use descriptive names like `workflow-duplicate` not `icon1`
4. **Theme support**: Always use `currentColor` for adaptable icons
5. **Test both themes**: Verify icons look good in light and dark modes
## Migration from PrimeIcons
When replacing a PrimeIcon with a custom icon:
```vue
<!-- Before: PrimeIcon -->
<Button icon="pi pi-box" />
<!-- After: Custom icon -->
<Button>
<template #icon>
<i-comfy:workflow />
</template>
</Button>
```
## Adding Icon Collections
To add an entire icon set from npm:
1. Install the icon package
2. Configure in `vite.config.mts`
3. Use with the appropriate prefix
See the [unplugin-icons documentation](https://github.com/unplugin/unplugin-icons) for details.

View File

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 5V3C14 2.44772 13.5523 2 13 2H11C10.4477 2 10 2.44772 10 3V5C10 5.55228 10.4477 6 11 6H13C13.5523 6 14 5.55228 14 5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M6 5V3C6 2.44772 5.55228 2 5 2H3C2.44772 2 2 2.44772 2 3V5C2 5.55228 2.44772 6 3 6H5C5.55228 6 6 5.55228 6 5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M14 13V11C14 10.4477 13.5523 10 13 10H11C10.4477 10 10 10.4477 10 11V13C10 13.5523 10.4477 14 11 14H13C13.5523 14 14 13.5523 14 13Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10 4H6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10 12H8C5.79086 12 4 10.2091 4 8V6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 890 B

View File

@@ -30,10 +30,11 @@ import ComfyQueueButton from './ComfyQueueButton.vue'
const settingsStore = useSettingStore()
const visible = computed(
() => settingsStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
@@ -49,7 +50,16 @@ const {
} = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
containerElement: document.body,
onMove: (event) => {
// Prevent dragging the menu over the top of the tabs
if (position.value === 'Top') {
const minY = topMenuRef?.value?.getBoundingClientRect().top ?? 40
if (event.y < minY) {
event.y = minY
}
}
}
})
// Update storedPosition when x or y changes
@@ -182,7 +192,6 @@ const adjustMenuPosition = () => {
useEventListener(window, 'resize', adjustMenuPosition)
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const topMenuBounds = useElementBounding(topMenuRef)
const overlapThreshold = 20 // pixels
const isOverlappingWithTopMenu = computed(() => {

View File

@@ -1,47 +1,95 @@
<template>
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
<div
class="subgraph-breadcrumb w-auto"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
}"
:style="{
'--p-breadcrumb-gap': `${ITEM_GAP}px`,
'--p-breadcrumb-item-min-width': `${MIN_WIDTH}px`,
'--p-breadcrumb-item-padding': `${ITEM_PADDING}px`,
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<Breadcrumb
class="bg-transparent"
:home="home"
ref="breadcrumbRef"
class="bg-transparent p-0"
:model="items"
aria-label="Graph navigation"
@item-click="handleItemClick"
/>
>
<template #item="{ item }">
<SubgraphBreadcrumbItem
:item="item"
:is-active="item === items.at(-1)"
/>
</template>
<template #separator
><span style="transform: scale(1.5)"> / </span></template
>
</Breadcrumb>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import { computed } from 'vue'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
const MIN_WIDTH = 28
const ITEM_GAP = 8
const ITEM_PADDING = 8
const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
const items = computed(() => {
if (!navigationStore.navigationStack.length) return []
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
const items = 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)
},
updateTitle: (title: string) => {
const rootGraph = useCanvasStore().getCanvas().graph?.rootGraph
if (!rootGraph) return
forEachSubgraphNode(rootGraph, subgraph.id, (node) => {
node.title = title
})
}
}))
return [home.value, ...items]
})
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -50,10 +98,6 @@ const home = computed(() => ({
}
}))
const handleItemClick = (event: MenuItemCommandEvent) => {
event.item.command?.(event)
}
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
@@ -65,21 +109,116 @@ useEventListener(document, 'keydown', (event) => {
)
}
})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
watch(breadcrumbElement, (el) => {
overflowObserver?.dispose()
overflowObserver = undefined
if (!el) return
overflowObserver = useOverflowObserver(el, {
onCheck: (isOverflowing) => {
overflowingTabs.value = isOverflowing
if (collapseTabs.value) {
// Items are currently hidden, check if we can show them
if (!isOverflowing) {
const items = [
...el.querySelectorAll('.p-breadcrumb-item')
] as HTMLElement[]
if (items.length < 3) return
const itemsWithIcon = items.filter((item) =>
item.querySelector('.p-breadcrumb-item-link-icon-visible')
).length
const separators = el.querySelectorAll(
'.p-breadcrumb-separator'
) as NodeListOf<HTMLElement>
const separator = separators[separators.length - 1] as HTMLElement
const separatorWidth = separator.offsetWidth
// items + separators + gaps + icons
const itemsWidth =
(MIN_WIDTH + ITEM_PADDING + ITEM_PADDING) * items.length +
itemsWithIcon * ICON_WIDTH
const separatorsWidth = (items.length - 1) * separatorWidth
const gapsWidth = (items.length - 1) * (ITEM_GAP * 2)
const totalWidth = itemsWidth + separatorsWidth + gapsWidth
const containerWidth = el.clientWidth
if (totalWidth <= containerWidth) {
collapseTabs.value = false
}
}
} else if (isOverflowing) {
collapseTabs.value = true
}
}
})
})
// If e.g. the workflow name changes, we need to check the overflow again
onUpdated(() => {
if (!overflowObserver?.disposed.value) {
overflowObserver?.checkOverflow()
}
})
</script>
<style>
.subgraph-breadcrumb {
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
<style scoped>
.subgraph-breadcrumb:not(:empty) {
flex: auto;
flex-shrink: 10000;
min-width: 120px;
}
color: #d26565;
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
0 0 0.375rem #000;
.subgraph-breadcrumb,
:deep(.p-breadcrumb) {
@apply overflow-hidden;
}
:deep(.p-breadcrumb-item) {
@apply flex items-center rounded-lg overflow-hidden;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
/* Collapse middle items first */
flex-shrink: 10000;
}
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-icon-visible)) {
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem + 20px);
}
:deep(.p-breadcrumb-item:first-child) {
/* Then collapse the root workflow */
flex-shrink: 5000;
}
:deep(.p-breadcrumb-item:last-child) {
/* Then collapse the active item */
flex-shrink: 1;
}
:deep(.p-breadcrumb-item:hover),
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-menu-visible)) {
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
color: var(--fg-color);
}
</style>
<style>
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
.p-breadcrumb-item,
.p-breadcrumb-separator {
@apply hidden;
}
.p-breadcrumb-item:nth-last-child(3),
.p-breadcrumb-separator:nth-last-child(2),
.p-breadcrumb-item:nth-last-child(1) {
@apply block;
}
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<a
ref="wrapperRef"
v-tooltip.bottom="{
value: item.label,
showDelay: 512
}"
href="#"
class="cursor-pointer p-breadcrumb-item-link"
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
'p-breadcrumb-item-link-icon-visible': isActive
}"
@click="handleClick"
>
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
v-if="isActive"
ref="menu"
:model="menuItems"
:popup="true"
:pt="{
root: {
style: 'background-color: var(--comfy-menu-secondary-bg)'
},
itemLink: {
class: 'py-2'
}
}"
/>
<InputText
v-if="isEditing"
ref="itemInputRef"
v-model="itemLabel"
class="fixed z-[10000] text-[.8rem] px-2 py-2"
@blur="inputBlur(true)"
@click.stop
@keydown.enter="inputBlur(true)"
@keydown.esc="inputBlur(false)"
/>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import Menu, { MenuState } from 'primevue/menu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { appendJsonExt } from '@/utils/formatUtil'
interface Props {
item: MenuItem
isActive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { t } = useI18n()
const menu = ref<InstanceType<typeof Menu> & MenuState>()
const dialogService = useDialogService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const isEditing = ref(false)
const itemLabel = ref<string>()
const itemInputRef = ref<{ $el?: HTMLInputElement }>()
const wrapperRef = ref<HTMLAnchorElement>()
const rename = async (
newName: string | null | undefined,
initialName: string
) => {
if (newName && newName !== initialName) {
// Synchronize the node titles with the new name
props.item.updateTitle?.(newName)
if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName
} else if (workflowStore.activeWorkflow) {
try {
await workflowService.renameWorkflow(
workflowStore.activeWorkflow,
ComfyWorkflow.basePath + appendJsonExt(newName)
)
} catch (error) {
console.error(error)
dialogService.showErrorDialog(error)
return
}
}
// Force the navigation stack to recompute the labels
// TODO: investigate if there is a better way to do this
const navigationStore = useSubgraphNavigationStore()
navigationStore.restoreState(navigationStore.exportState())
}
}
const menuItems = computed<MenuItem[]>(() => {
return [
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: async () => {
let initialName =
workflowStore.activeSubgraph?.name ??
workflowStore.activeWorkflow?.filename
if (!initialName) return
const newName = await dialogService.prompt({
title: t('g.rename'),
message: t('breadcrumbsMenu.enterNewName'),
defaultValue: initialName
})
await rename(newName, initialName)
}
},
{
label: t('breadcrumbsMenu.duplicate'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root'
},
{
separator: true
},
{
label: t('breadcrumbsMenu.clearWorkflow'),
icon: 'pi pi-trash',
command: async () => {
await useCommandStore().execute('Comfy.ClearWorkflow')
}
},
{
separator: true,
visible: props.item.key === 'root'
},
{
label: t('breadcrumbsMenu.deleteWorkflow'),
icon: 'pi pi-times',
command: async () => {
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root'
}
]
})
const handleClick = (event: MouseEvent) => {
if (isEditing.value) {
return
}
if (event.detail === 1) {
if (props.isActive) {
menu.value?.toggle(event)
} else {
props.item.command?.({ item: props.item, originalEvent: event })
}
} else if (props.isActive && event.detail === 2) {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
}
}
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
}
isEditing.value = false
}
</script>
<style scoped>
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
}
.p-breadcrumb-item-link {
@apply overflow-hidden;
padding: var(--p-breadcrumb-item-padding);
}
.p-breadcrumb-item-label {
@apply whitespace-nowrap text-ellipsis overflow-hidden;
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div
class="inline-flex items-center justify-center"
:style="{ width: size + 'px', height: size + 'px' }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 14 14"
fill="none"
class="animate-spin"
:style="{ animationDuration: duration }"
>
<g clip-path="url(#clip0_776_9582)">
<!-- Top dot -->
<path
class="dot-animation"
style="animation-delay: 0s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M7 2.21053C7.61042 2.21053 8.10526 1.71568 8.10526 1.10526C8.10526 0.494843 7.61042 0 7 0C6.38958 0 5.89474 0.494843 5.89474 1.10526C5.89474 1.71568 6.38958 2.21053 7 2.21053Z"
:fill="color"
/>
<!-- Left dot -->
<path
class="dot-animation"
style="animation-delay: 0.25s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.21053 7C2.21053 7.61042 1.71568 8.10526 1.10526 8.10526C0.494843 8.10526 0 7.61042 0 7C0 6.38958 0.494843 5.89474 1.10526 5.89474C1.71568 5.89474 2.21053 6.38958 2.21053 7Z"
:fill="color"
/>
<!-- Right dot -->
<path
class="dot-animation"
style="animation-delay: 0.5s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M14 7C14 7.61042 13.5052 8.10526 12.8947 8.10526C12.2843 8.10526 11.7895 7.61042 11.7895 7C11.7895 6.38958 12.2843 5.89474 12.8947 5.89474C13.5052 5.89474 14 6.38958 14 7Z"
:fill="color"
/>
<!-- Bottom dot -->
<path
class="dot-animation"
style="animation-delay: 0.75s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.10526 12.8947C8.10526 13.5052 7.61041 14 6.99999 14C6.38957 14 5.89473 13.5052 5.89473 12.8947C5.89473 12.2843 6.38957 11.7895 6.99999 11.7895C7.61041 11.7895 8.10526 12.2843 8.10526 12.8947Z"
:fill="color"
/>
<!-- Top-left dot -->
<path
class="dot-animation"
style="animation-delay: 0.125s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.05039 3.61349C2.48203 4.04513 3.18184 4.04513 3.61347 3.61349C4.0451 3.18186 4.0451 2.48205 3.61347 2.05042C3.18184 1.61878 2.48203 1.61878 2.05039 2.05042C1.61876 2.48205 1.61876 3.18186 2.05039 3.61349Z"
:fill="color"
/>
<!-- Bottom-right dot -->
<path
class="dot-animation"
style="animation-delay: 0.625s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.9496 11.9496C11.518 12.3812 10.8182 12.3812 10.3865 11.9496C9.9549 11.5179 9.9549 10.8181 10.3865 10.3865C10.8182 9.95485 11.518 9.95485 11.9496 10.3865C12.3812 10.8181 12.3812 11.5179 11.9496 11.9496Z"
:fill="color"
/>
<!-- Bottom-left dot -->
<path
class="dot-animation"
style="animation-delay: 0.875s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.05039 11.9496C2.48203 12.3812 3.18184 12.3812 3.61347 11.9496C4.0451 11.5179 4.0451 10.8181 3.61347 10.3865C3.18184 9.95485 2.48203 9.95485 2.05039 10.3865C1.61876 10.8181 1.61876 11.5179 2.05039 11.9496Z"
:fill="color"
/>
<!-- Top-right dot -->
<path
class="dot-animation"
style="animation-delay: 0.375s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.9496 3.61349C11.518 4.04513 10.8182 4.04513 10.3865 3.61349C9.9549 3.18186 9.9549 2.48205 10.3865 2.05042C10.8182 1.61878 11.518 1.61878 11.9496 2.05042C12.3812 2.48205 12.3812 3.18186 11.9496 3.61349Z"
:fill="color"
/>
</g>
<defs>
<clipPath id="clip0_776_9582">
<rect width="14" height="14" fill="white" />
</clipPath>
</defs>
</svg>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const { size = 24, duration = '2s' } = defineProps<{
size?: number
duration?: string
}>()
const colorPaletteStore = useColorPaletteStore()
const color = computed(() =>
colorPaletteStore.completedActivePalette.light_theme ? '#2C2B30' : '#D4D4D4'
)
</script>
<style scoped>
.dot-animation {
animation: dot-pulse 1s ease-in-out infinite;
}
@keyframes dot-pulse {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
</style>

View File

@@ -27,7 +27,7 @@
/>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" />
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</Dialog>
</template>

View File

@@ -2,7 +2,7 @@
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
title="Missing Node Types"
title="Some Nodes Are Missing"
message="When loading the graph, the following node types were not found"
/>
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
@@ -31,7 +31,7 @@
</div>
</template>
</ListBox>
<div v-if="isManagerInstalled" class="flex justify-end py-3">
<div v-if="!isLegacyManager" class="flex justify-end py-3">
<PackInstallButton
:disabled="isLoading || !!error || missingNodePacks.length === 0"
:node-packs="missingNodePacks"
@@ -45,14 +45,14 @@
<script setup lang="ts">
import Button from 'primevue/button'
import ListBox from 'primevue/listbox'
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -60,22 +60,11 @@ const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
const aboutPanelStore = useAboutPanelStore()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core
const isManagerInstalled = computed(() => {
return aboutPanelStore.badges.some(
(badge) =>
badge.label.includes('ComfyUI-Manager') ||
badge.url.includes('ComfyUI-Manager')
)
})
const isLegacyManager = ref(false)
const uniqueNodes = computed(() => {
const seenTypes = new Set()
@@ -103,6 +92,13 @@ const openManager = () => {
initialTab: ManagerTab.Missing
})
}
onMounted(async () => {
const isLegacyResponse = await useComfyManagerService().isLegacyManagerUI()
if (isLegacyResponse?.is_legacy_manager_ui) {
isLegacyManager.value = true
}
})
</script>
<style scoped>

View File

@@ -30,11 +30,20 @@ const defaultMockTaskLogs = [
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs]
taskLogs: [...defaultMockTaskLogs],
succeededTasksLogs: [...defaultMockTaskLogs],
failedTasksLogs: [...defaultMockTaskLogs],
managerQueue: { historyCount: 2 },
isLoading: false
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
collapse: mockCollapse
activeTabIndex: 0,
getActiveTabIndex: vi.fn(() => 0),
setActiveTabIndex: vi.fn(),
toggle: vi.fn(),
collapse: mockCollapse,
expand: vi.fn()
}))
}))

View File

@@ -18,7 +18,7 @@
'max-h-0': !isExpanded
}"
>
<div v-for="(panel, index) in taskPanels" :key="index">
<div v-for="(log, index) in focusedLogs" :key="index">
<Panel
:expanded="collapsedPanels[index] || false"
toggleable
@@ -27,7 +27,7 @@
<template #header>
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ panel.taskName }}</span>
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isInProgress(index)
@@ -52,24 +52,24 @@
</template>
<div
:ref="
index === taskPanels.length - 1
index === focusedLogs.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== taskPanels.length - 1,
'flex-grow': index === taskPanels.length - 1
'h-64': index !== focusedLogs.length - 1,
'flex-grow': index === focusedLogs.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(log, logIndex) in panel.logs"
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"
class="text-neutral-400 dark-theme:text-muted"
>
<pre class="whitespace-pre-wrap break-words">{{ log }}</pre>
<pre class="whitespace-pre-wrap break-words">{{ logLine }}</pre>
</div>
</div>
</div>
@@ -90,17 +90,23 @@ import {
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
const { taskLogs } = useComfyManagerStore()
const comfyManagerStore = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const managerStore = useComfyManagerStore()
const isInProgress = (index: number) =>
index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
index === comfyManagerStore.managerQueue.historyCount - 1 &&
comfyManagerStore.isLoading
const taskPanels = computed(() => taskLogs)
const isExpanded = computed(() => progressDialogContent.isExpanded)
const isCollapsed = computed(() => !isExpanded.value)
const focusedLogs = computed(() => {
if (progressDialogContent.getActiveTabIndex() === 0) {
return comfyManagerStore.succeededTasksLogs
}
return comfyManagerStore.failedTasksLogs
})
const collapsedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
collapsedPanels.value[index] = !collapsedPanels.value[index]
@@ -115,7 +121,7 @@ const { y: scrollY } = useScroll(sectionsContainerRef, {
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => taskPanels.value?.at(-1)?.logs)
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false

View File

@@ -26,6 +26,35 @@
}"
>
<div class="px-6 flex flex-col h-full">
<!-- Conflict Warning Banner -->
<div
v-if="shouldShowManagerBanner"
class="bg-yellow-600 bg-opacity-20 border border-yellow-400 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative"
>
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i>
<div class="flex flex-col gap-2 flex-1">
<p class="text-sm font-bold m-0">
{{ $t('manager.conflicts.warningBanner.title') }}
</p>
<p class="text-xs m-0">
{{ $t('manager.conflicts.warningBanner.message') }}
</p>
<p
class="text-sm font-bold m-0 cursor-pointer"
@click="onClickWarningLink"
>
{{ $t('manager.conflicts.warningBanner.button') }}
</p>
</div>
<button
type="button"
class="absolute top-2 right-2 w-6 h-6 border-none outline-none bg-transparent flex items-center justify-center text-yellow-600 rounded transition-colors"
:aria-label="$t('g.close')"
@click="dismissWarningBanner"
>
<i class="pi pi-times text-sm"></i>
</button>
</div>
<RegistrySearchBar
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
@@ -34,6 +63,7 @@
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
:is-update-available-tab="isUpdateAvailableTab"
/>
<div class="flex-1 overflow-auto">
<div
@@ -69,7 +99,9 @@
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
@click.stop="(event) => selectNodePack(item, event)"
@click.stop="
(event: MouseEvent) => selectNodePack(item, event)
"
/>
</template>
</VirtualGrid>
@@ -101,7 +133,8 @@ import {
onMounted,
onUnmounted,
ref,
watch
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -119,6 +152,7 @@ import { useManagerStatePersistence } from '@/composables/manager/useManagerStat
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
@@ -133,6 +167,7 @@ const { initialTab } = defineProps<{
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const conflictAcknowledgment = useConflictAcknowledgment()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
@@ -149,6 +184,13 @@ const {
toggle: toggleSideNav
} = useResponsiveCollapse()
// Use conflict acknowledgment state from composable
const {
shouldShowManagerBanner,
dismissWarningBanner,
dismissRedDotNotification
} = conflictAcknowledgment
const tabs = ref<TabItem[]>([
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
@@ -312,6 +354,13 @@ watch([isAllTab, searchResults], () => {
displayPacks.value = searchResults.value
})
const onClickWarningLink = () => {
window.open(
'https://docs.comfy.org/troubleshooting/custom-node-issues',
'_blank'
)
}
const onResultsChange = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
@@ -440,6 +489,7 @@ whenever(selectedNodePack, async () => {
if (hasMultipleSelections.value) return
// Only fetch if we haven't already for this pack
if (lastFetchedPackId.value === pack.id) return
const data = await getPackById.call(pack.id)
// If selected node hasn't changed since request, merge registry & Algolia data
if (data?.id === pack.id) {
@@ -472,6 +522,10 @@ watch([searchQuery, selectedTab], () => {
}
})
watchEffect(() => {
dismissRedDotNotification()
})
onBeforeUnmount(() => {
persistedState.persistState({
selectedTabId: selectedTab.value?.id,

View File

@@ -0,0 +1,82 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tag from 'primevue/tag'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import ManagerHeader from './ManagerHeader.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
describe('ManagerHeader', () => {
const createWrapper = () => {
return mount(ManagerHeader, {
global: {
plugins: [createPinia(), PrimeVue, i18n],
directives: {
tooltip: Tooltip
},
components: {
Tag
}
}
})
}
it('renders the component title', () => {
const wrapper = createWrapper()
expect(wrapper.find('h2').text()).toBe(
enMessages.manager.discoverCommunityContent
)
})
it('displays the legacy manager UI tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.exists()).toBe(true)
expect(tag.text()).toContain(enMessages.manager.legacyManagerUI)
})
it('applies info severity to the tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.classes()).toContain('p-tag-info')
})
it('displays info icon in the tag', () => {
const wrapper = createWrapper()
const icon = wrapper.find('.pi-info-circle')
expect(icon.exists()).toBe(true)
})
it('has cursor-help class on the tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.classes()).toContain('cursor-help')
})
it('has proper structure with flex container', () => {
const wrapper = createWrapper()
const flexContainer = wrapper.find('.flex.justify-end.ml-auto.pr-4')
expect(flexContainer.exists()).toBe(true)
const tag = flexContainer.find('[data-pc-name="tag"]')
expect(tag.exists()).toBe(true)
})
})

View File

@@ -4,6 +4,22 @@
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
<div class="flex justify-end ml-auto pr-4">
<Tag
v-tooltip.left="$t('manager.legacyManagerUIDescription')"
severity="info"
icon="pi pi-info-circle"
:value="$t('manager.legacyManagerUI')"
class="cursor-help"
:pt="{
root: { class: 'text-xs' }
}"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
</script>

View File

@@ -0,0 +1,231 @@
<template>
<div class="w-[552px] flex flex-col">
<ContentDivider :width="1" />
<div class="px-4 py-6 w-full h-full flex flex-col gap-2">
<!-- Description -->
<div v-if="showAfterWhatsNew">
<p
class="text-sm leading-4 text-neutral-800 dark-theme:text-white m-0 mb-4"
>
{{ $t('manager.conflicts.description') }}
<br /><br />
{{ $t('manager.conflicts.info') }}
</p>
</div>
<!-- Import Failed List Wrapper -->
<div
v-if="importFailedConflicts.length > 0"
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleImportFailedPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ importFailedConflicts.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.importFailedExtensions') }}</span
>
</div>
<div>
<Button
:icon="
importFailedExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Import failed list -->
<div
v-if="importFailedExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
{{ packageName }}
</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Conflict List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleConflictsPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ allConflictDetails.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.conflicts') }}</span
>
</div>
<div>
<Button
:icon="
conflictsExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Conflicts list -->
<div
v-if="conflictsExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span
class="text-xs text-neutral-600 dark-theme:text-neutral-300"
>{{ getConflictMessage(conflict, t) }}</span
>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Extension List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleExtensionsPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ conflictData.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.extensionAtRisk') }}</span
>
</div>
<div>
<Button
:icon="
extensionsExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Extension list -->
<div
v-if="extensionsExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
{{ conflictResult.package_name }}
</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
</div>
<ContentDivider :width="1" />
</div>
</template>
<script setup lang="ts">
import { filter, flatMap, map, some } from 'lodash'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
interface Props {
showAfterWhatsNew?: boolean
}
const { showAfterWhatsNew } = withDefaults(defineProps<Props>(), {
showAfterWhatsNew: false
})
const { t } = useI18n()
const { conflictedPackages } = useConflictDetection()
const conflictsExpanded = ref<boolean>(false)
const extensionsExpanded = ref<boolean>(false)
const importFailedExpanded = ref<boolean>(false)
const conflictData = computed(() => conflictedPackages.value)
const allConflictDetails = computed(() => {
const allConflicts = flatMap(conflictData.value, (result) => result.conflicts)
return filter(allConflicts, (conflict) => conflict.type !== 'import_failed')
})
const packagesWithImportFailed = computed(() => {
return filter(conflictData.value, (result) =>
some(result.conflicts, (conflict) => conflict.type === 'import_failed')
)
})
const importFailedConflicts = computed(() => {
return map(
packagesWithImportFailed.value,
(result) => result.package_name || result.package_id
)
})
const toggleImportFailedPanel = () => {
importFailedExpanded.value = !importFailedExpanded.value
conflictsExpanded.value = false
extensionsExpanded.value = false
}
const toggleConflictsPanel = () => {
conflictsExpanded.value = !conflictsExpanded.value
extensionsExpanded.value = false
importFailedExpanded.value = false
}
const toggleExtensionsPanel = () => {
extensionsExpanded.value = !extensionsExpanded.value
conflictsExpanded.value = false
importFailedExpanded.value = false
}
</script>
<style scoped>
.conflict-list-item:hover {
background-color: rgba(0, 122, 255, 0.2);
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="flex items-center justify-between w-full px-3 py-4">
<div class="w-full flex items-center justify-between gap-2 pr-1">
<Button
:label="$t('manager.conflicts.conflictInfoTitle')"
text
severity="secondary"
size="small"
icon="pi pi-info-circle"
:pt="{
label: { class: 'text-sm' }
}"
@click="handleConflictInfoClick"
/>
<Button
v-if="props.buttonText"
:label="props.buttonText"
severity="secondary"
size="small"
@click="handleButtonClick"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useDialogStore } from '@/stores/dialogStore'
interface Props {
buttonText?: string
onButtonClick?: () => void
}
const props = withDefaults(defineProps<Props>(), {
buttonText: undefined,
onButtonClick: undefined
})
const dialogStore = useDialogStore()
const handleConflictInfoClick = () => {
window.open(
'https://docs.comfy.org/troubleshooting/custom-node-issues',
'_blank'
)
}
const handleButtonClick = () => {
// Close the conflict dialog
dialogStore.closeDialog({ key: 'global-node-conflict' })
// Execute the custom button action if provided
if (props.onButtonClick) {
props.onButtonClick()
}
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="h-12 flex items-center justify-between w-full pl-6">
<div class="flex items-center gap-2">
<!-- Warning Icon -->
<i class="pi pi-exclamation-triangle text-lg"></i>
<!-- Title -->
<p class="text-base font-bold">
{{ $t('manager.conflicts.title') }}
</p>
</div>
</div>
</template>

View File

@@ -17,9 +17,10 @@
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import { computed, inject } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
type PackVersionStatus = components['schemas']['NodeVersionStatus']
type PackStatus = components['schemas']['NodeStatus']
@@ -32,10 +33,15 @@ type StatusProps = {
severity: MessageSeverity
}
const { statusType } = defineProps<{
const { statusType, hasCompatibilityIssues } = defineProps<{
statusType: Status
hasCompatibilityIssues?: boolean
}>()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const statusPropsMap: Record<Status, StatusProps> = {
NodeStatusActive: {
label: 'active',
@@ -71,10 +77,14 @@ const statusPropsMap: Record<Status, StatusProps> = {
}
}
const statusLabel = computed(
() => statusPropsMap[statusType]?.label || 'unknown'
)
const statusSeverity = computed(
() => statusPropsMap[statusType]?.severity || 'secondary'
)
const statusLabel = computed(() => {
if (importFailed?.value) return 'importFailed'
if (hasCompatibilityIssues) return 'conflicting'
return statusPropsMap[statusType]?.label || 'unknown'
})
const statusSeverity = computed(() => {
if (hasCompatibilityIssues || importFailed?.value) return 'error'
return statusPropsMap[statusType]?.severity || 'secondary'
})
</script>

View File

@@ -6,11 +6,18 @@ import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionBadge from './PackVersionBadge.vue'
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Mock config to prevent __COMFYUI_FRONTEND_VERSION__ error
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
@@ -120,7 +127,7 @@ describe('PackVersionBadge', () => {
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
expect(badge.find('span').text()).toBe('nightly')
})
it('falls back to NIGHTLY when nodePack.id is missing', () => {
@@ -134,7 +141,7 @@ describe('PackVersionBadge', () => {
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
expect(badge.find('span').text()).toBe('nightly')
})
it('toggles the popover when button is clicked', async () => {

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }"
aria-haspopup="true"
role="button"
tabindex="0"
@@ -12,8 +12,7 @@
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600"
style="font-size: 8px"
class="pi pi-arrow-circle-up text-blue-600 text-xs"
/>
<span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right" style="font-size: 8px" />
@@ -22,7 +21,7 @@
<Popover
ref="popoverRef"
:pt="{
content: { class: 'px-0' }
content: { class: 'p-0 shadow-lg' }
}"
>
<PackVersionSelectorPopover
@@ -42,8 +41,7 @@ import { computed, ref, watch } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
@@ -64,11 +62,11 @@ const popoverRef = ref()
const managerStore = useComfyManagerStore()
const installedVersion = computed(() => {
if (!nodePack.id) return SelectedVersion.NIGHTLY
if (!nodePack.id) return 'nightly'
const version =
managerStore.installedPacks[nodePack.id]?.ver ??
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
'nightly'
// If Git hash, truncate to 7 characters
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)

View File

@@ -3,18 +3,32 @@ import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Listbox from 'primevue/listbox'
import Select from 'primevue/select'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
// SelectedVersion is now using direct strings instead of enum
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Default mock versions for reference
const defaultMockVersions = [
{ version: '1.0.0', createdAt: '2023-01-01' },
{
version: '1.0.0',
createdAt: '2023-01-01',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
},
{ version: '0.9.0', createdAt: '2022-12-01' },
{ version: '0.8.0', createdAt: '2022-11-01' }
]
@@ -22,13 +36,24 @@ const defaultMockVersions = [
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: { version: '1.0.0' },
repository: 'https://github.com/user/repo'
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
},
repository: 'https://github.com/user/repo',
has_registry_data: true
}
// Create mock functions
const mockGetPackVersions = vi.fn()
const mockInstallPack = vi.fn().mockResolvedValue(undefined)
const mockCheckNodeCompatibility = vi.fn()
// Mock the registry service
vi.mock('@/services/comfyRegistryService', () => ({
@@ -49,6 +74,13 @@ vi.mock('@/stores/comfyManagerStore', () => ({
}))
}))
// Mock the conflict detection composable
vi.mock('@/composables/useConflictDetection', () => ({
useConflictDetection: vi.fn(() => ({
checkNodeCompatibility: mockCheckNodeCompatibility
}))
}))
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))
await nextTick()
@@ -59,6 +91,9 @@ describe('PackVersionSelectorPopover', () => {
vi.clearAllMocks()
mockGetPackVersions.mockReset()
mockInstallPack.mockReset().mockResolvedValue(undefined)
mockCheckNodeCompatibility
.mockReset()
.mockReturnValue({ hasConflict: false, conflicts: [] })
})
const mountComponent = ({
@@ -78,7 +113,12 @@ describe('PackVersionSelectorPopover', () => {
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Listbox
Listbox,
VerifiedIcon,
Select
},
directives: {
tooltip: Tooltip
}
}
})
@@ -120,14 +160,15 @@ describe('PackVersionSelectorPopover', () => {
const options = listbox.props('options')!
// Check that we have both special options and version options
expect(options.length).toBe(defaultMockVersions.length + 2) // 2 special options + version options
// Latest version (1.0.0) should be excluded from the version list to avoid duplication
expect(options.length).toBe(defaultMockVersions.length + 1) // 2 special options + version options minus 1 duplicate
// Check that special options exist
expect(options.some((o) => o.value === SelectedVersion.NIGHTLY)).toBe(true)
expect(options.some((o) => o.value === SelectedVersion.LATEST)).toBe(true)
expect(options.some((o) => o.value === 'nightly')).toBe(true)
expect(options.some((o) => o.value === 'latest')).toBe(true)
// Check that version options exist
expect(options.some((o) => o.value === '1.0.0')).toBe(true)
// Check that version options exist (excluding latest version 1.0.0)
expect(options.some((o) => o.value === '1.0.0')).toBe(false) // Should be excluded as it's the latest
expect(options.some((o) => o.value === '0.9.0')).toBe(true)
expect(options.some((o) => o.value === '0.8.0')).toBe(true)
})
@@ -304,7 +345,7 @@ describe('PackVersionSelectorPopover', () => {
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
expect(listbox.props('modelValue')).toBe('nightly')
})
it('defaults to nightly when publisher name is "Unclaimed"', async () => {
@@ -325,7 +366,343 @@ describe('PackVersionSelectorPopover', () => {
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
expect(listbox.props('modelValue')).toBe('nightly')
})
})
describe('version compatibility checking', () => {
it('shows warning icon for incompatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return conflict for specific version
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.supported_os?.includes('linux')) {
return {
hasConflict: true,
conflicts: [
{
type: 'os',
current_value: 'windows',
required_value: 'linux'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['linux'],
supported_accelerators: ['CUDA']
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for incompatible versions
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows verified icon for compatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return no conflicts
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The verified icon should be shown for compatible versions
// Look for the VerifiedIcon component or SVG elements
const verifiedIcons = wrapper.findAll('svg')
expect(verifiedIcons.length).toBeGreaterThan(0)
})
it('calls checkVersionCompatibility with correct version data', async () => {
// Set up the mock for versions with specific supported data
const versionsWithCompatibility = [
{
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CUDA', 'CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
]
mockGetPackVersions.mockResolvedValueOnce(versionsWithCompatibility)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()
// Trigger compatibility check by accessing getVersionCompatibility
const vm = wrapper.vm as any
vm.getVersionCompatibility('1.0.0')
// Verify that checkNodeCompatibility was called with correct data
// Since 1.0.0 is the latest version, it should use latest_version data
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0'
})
})
it('shows version conflict warnings for ComfyUI and frontend versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return version conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
const conflicts = []
if (versionData.supported_comfyui_version) {
conflicts.push({
type: 'comfyui_version',
current_value: '0.5.0',
required_value: versionData.supported_comfyui_version
})
}
if (versionData.supported_comfyui_frontend_version) {
conflicts.push({
type: 'frontend_version',
current_value: '1.0.0',
required_value: versionData.supported_comfyui_frontend_version
})
}
return {
hasConflict: conflicts.length > 0,
conflicts
}
})
const nodePackWithVersionRequirements = {
...mockNodePack,
supported_comfyui_version: '>=1.0.0',
supported_comfyui_frontend_version: '>=2.0.0'
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithVersionRequirements }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for version incompatible packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('handles latest and nightly versions using nodePack data', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
...mockNodePack.latest_version,
supported_os: ['windows'], // Match nodePack data for test consistency
supported_accelerators: ['CPU'], // Match nodePack data for test consistency
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
const vm = wrapper.vm as any
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()
// Test latest version
vm.getVersionCompatibility('latest')
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0'
})
// Clear for next test call
mockCheckNodeCompatibility.mockClear()
// Test nightly version
vm.getVersionCompatibility('nightly')
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
id: 'test-pack',
name: 'Test Pack',
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
repository: 'https://github.com/user/repo',
has_registry_data: true,
latest_version: {
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0',
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
})
})
it('shows banned package warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return banned conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.is_banned === true) {
return {
hasConflict: true,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const bannedNodePack = {
...mockNodePack,
is_banned: true,
latest_version: {
...mockNodePack.latest_version,
is_banned: true
}
}
const wrapper = mountComponent({
props: { nodePack: bannedNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// Open the dropdown to see the options
const select = wrapper.find('.p-select')
if (!select.exists()) {
// Try alternative selector
const selectButton = wrapper.find('[aria-haspopup="listbox"]')
if (selectButton.exists()) {
await selectButton.trigger('click')
}
} else {
await select.trigger('click')
}
await wrapper.vm.$nextTick()
// The warning icon should be shown for banned packages in the dropdown options
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows security pending warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return security pending conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.has_registry_data === false) {
return {
hasConflict: true,
conflicts: [
{
type: 'pending',
current_value: 'no_registry_data',
required_value: 'registry_data_available'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const securityPendingNodePack = {
...mockNodePack,
has_registry_data: false,
latest_version: {
...mockNodePack.latest_version,
has_registry_data: false
}
}
const wrapper = mountComponent({
props: { nodePack: securityPendingNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for security pending packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
})
})

View File

@@ -1,8 +1,10 @@
<template>
<div class="w-64 mt-2">
<span class="pl-3 text-muted text-md font-semibold opacity-70">
{{ $t('manager.selectVersion') }}
</span>
<div class="w-64 pt-1">
<div class="py-2">
<span class="pl-3 text-md font-semibold text-neutral-500">
{{ $t('manager.selectVersion') }}
</span>
</div>
<div
v-if="isLoadingVersions || isQueueing"
class="text-center text-muted py-4 flex flex-col items-center"
@@ -23,24 +25,44 @@
v-model="selectedVersion"
option-label="label"
option-value="value"
:options="versionOptions"
:options="processedVersionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none shadow-none"
class="w-full max-h-[50vh] border-none shadow-none rounded-md"
:pt="{
listContainer: { class: 'scrollbar-hide' }
}"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">
<span>{{ slotProps.option.label }}</span>
<div class="flex items-center gap-2">
<template v-if="slotProps.option.value === 'nightly'">
<div class="w-4"></div>
</template>
<template v-else>
<i
v-if="slotProps.option.hasConflict"
v-tooltip="{
value: slotProps.option.conflictMessage,
showDelay: 300
}"
class="pi pi-exclamation-triangle text-yellow-500"
/>
<VerifiedIcon v-else :size="20" class="relative right-0.5" />
</template>
<span>{{ slotProps.option.label }}</span>
</div>
<i
v-if="selectedVersion === slotProps.option.value"
v-if="slotProps.option.isSelected"
class="pi pi-check text-highlight"
/>
</div>
</template>
</Listbox>
<ContentDivider class="my-2" />
<div class="flex justify-end gap-2 p-1 px-3">
<div class="flex justify-end gap-2 py-1 px-3">
<Button
text
class="text-sm"
severity="secondary"
:label="$t('g.cancel')"
:disabled="isQueueing"
@@ -49,7 +71,7 @@
<Button
severity="secondary"
:label="$t('g.install')"
class="py-3 px-4 dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
class="py-2.5 px-4 text-sm dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
:disabled="isQueueing"
@click="handleSubmit"
/>
@@ -62,19 +84,18 @@ import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
import { isSemVer } from '@/utils/formatUtil'
const { nodePack } = defineProps<{
@@ -89,27 +110,30 @@ const emit = defineEmits<{
const { t } = useI18n()
const registryService = useComfyRegistryService()
const managerStore = useComfyManagerStore()
const { checkNodeCompatibility } = useConflictDetection()
const isQueueing = ref(false)
const selectedVersion = ref<string>(SelectedVersion.LATEST)
const selectedVersion = ref<string>('latest')
onMounted(() => {
const initialVersion = getInitialSelectedVersion() ?? SelectedVersion.LATEST
const initialVersion = getInitialSelectedVersion() ?? 'latest'
selectedVersion.value =
// Use NIGHTLY when version is a Git hash
isSemVer(initialVersion) ? initialVersion : SelectedVersion.NIGHTLY
isSemVer(initialVersion) ? initialVersion : 'nightly'
})
const getInitialSelectedVersion = () => {
if (!nodePack.id) return
// If unclaimed, set selected version to nightly
if (nodePack.publisher?.name === 'Unclaimed') return SelectedVersion.NIGHTLY
// If node pack is installed, set selected version to the installed version
if (managerStore.isPackInstalled(nodePack.id))
return managerStore.getInstalledPackVersion(nodePack.id)
// If unclaimed, set selected version to nightly
if (nodePack.publisher?.name === 'Unclaimed')
return 'nightly' as ManagerComponents['schemas']['SelectedVersion']
// If node pack is not installed, set selected version to latest
return nodePack.latest_version?.version
}
@@ -126,6 +150,9 @@ const versionOptions = ref<
}[]
>([])
// Store fetched versions with their full data
const fetchedVersions = ref<components['schemas']['NodeVersion'][]>([])
const isLoadingVersions = ref(false)
const onNodePackChange = async () => {
@@ -133,26 +160,35 @@ const onNodePackChange = async () => {
// Fetch versions from the registry
const versions = await fetchVersions()
fetchedVersions.value = versions
// Get latest version number to exclude from the list
const latestVersionNumber = nodePack.latest_version?.version
const availableVersionOptions = versions
.map((version) => ({
value: version.version ?? '',
label: version.version ?? ''
}))
.filter((option) => option.value)
.filter((option) => option.value && option.value !== latestVersionNumber) // Exclude latest version from the list
// Add Latest option with actual version number
const latestLabel = latestVersionNumber
? `${t('manager.latestVersion')} (${latestVersionNumber})`
: t('manager.latestVersion')
// Add Latest option
const defaultVersions = [
{
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
value: 'latest' as ManagerComponents['schemas']['SelectedVersion'],
label: latestLabel
}
]
// Add Nightly option if there is a non-empty `repository` field
if (nodePack.repository?.length) {
defaultVersions.push({
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
value: 'nightly' as ManagerComponents['schemas']['SelectedVersion'],
label: t('manager.nightlyVersion') // Keep as just "nightly" - no version number
})
}
@@ -172,16 +208,95 @@ whenever(
const handleSubmit = async () => {
isQueueing.value = true
if (!nodePack.id) {
throw new Error('Node ID is required for installation')
}
// Convert 'latest' to actual version number for installation
const actualVersion =
selectedVersion.value === 'latest'
? nodePack.latest_version?.version ?? 'latest'
: selectedVersion.value
await managerStore.installPack.call({
id: nodePack.id,
version: actualVersion,
repository: nodePack.repository ?? '',
channel: ManagerChannel.DEFAULT,
mode: ManagerDatabaseSource.CACHE,
version: selectedVersion.value,
selected_version: selectedVersion.value
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: actualVersion
})
isQueueing.value = false
emit('submit')
}
const getVersionData = (version: string) => {
const latestVersionNumber = nodePack.latest_version?.version
const useLatestVersionData =
version === 'latest' || version === latestVersionNumber
if (useLatestVersionData) {
const latestVersionData = nodePack.latest_version
return {
...latestVersionData
}
}
const versionData = fetchedVersions.value.find((v) => v.version === version)
if (versionData) {
return {
...versionData
}
}
// Fallback to nodePack data
return {
...nodePack
}
}
// Main function to get version compatibility info
const getVersionCompatibility = (version: string) => {
const versionData = getVersionData(version)
const compatibility = checkNodeCompatibility(versionData)
const conflictMessage = compatibility.hasConflict
? getJoinedConflictMessages(compatibility.conflicts, t)
: ''
return {
hasConflict: compatibility.hasConflict,
conflictMessage
}
}
// Helper to determine if an option is selected.
const isOptionSelected = (optionValue: string) => {
if (selectedVersion.value === optionValue) {
return true
}
if (
optionValue === 'latest' &&
selectedVersion.value === nodePack.latest_version?.version
) {
return true
}
return false
}
// Checks if an option is selected, treating 'latest' as an alias for the actual latest version number.
const processedVersionOptions = computed(() => {
return versionOptions.value.map((option) => {
const compatibility = getVersionCompatibility(option.value)
const isSelected = isOptionSelected(option.value)
return {
...option,
hasConflict: compatibility.hasConflict,
conflictMessage: compatibility.conflictMessage,
isSelected: isSelected
}
})
})
</script>

View File

@@ -1,18 +1,24 @@
<template>
<Button
outlined
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
class="!m-0 p-0 max-w-[120px] rounded-lg text-gray-900 dark-theme:text-gray-50"
:class="[
variant === 'black'
? 'bg-neutral-900 text-white border-neutral-900'
: 'border-neutral-700',
: variant === 'red'
? 'border-red-500'
: 'border-neutral-700',
fullWidth ? 'w-full' : 'w-min-content'
]"
:disabled="loading"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2 px-3 whitespace-nowrap">
<span class="py-1.5 px-3 whitespace-nowrap text-xs flex items-center gap-2">
<i
v-if="hasWarning && !loading"
class="pi pi-exclamation-triangle text-yellow-500"
></i>
<template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }}
</template>
@@ -28,15 +34,18 @@ import Button from 'primevue/button'
const {
label,
loading = false,
loadingMessage,
fullWidth = false,
variant = 'default'
variant = 'default',
hasWarning = false
} = defineProps<{
label: string
loading?: boolean
loadingMessage?: string
fullWidth?: boolean
variant?: 'default' | 'black'
variant?: 'default' | 'black' | 'red'
hasWarning?: boolean
}>()
const emit = defineEmits<{

View File

@@ -11,9 +11,22 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import PackEnableToggle from './PackEnableToggle.vue'
// Mock debounce to execute immediately
vi.mock('lodash', () => ({
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
// Mock lodash functions used throughout the app
vi.mock('lodash', async (importOriginal) => {
const actual = await importOriginal<typeof import('lodash')>()
return {
...actual,
debounce: <T extends (...args: any[]) => any>(fn: T) => fn,
memoize: <T extends (...args: any[]) => any>(fn: T) => fn
}
})
// Mock config to prevent __COMFYUI_FRONTEND_VERSION__ error
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
const mockNodePack = {

View File

@@ -1,6 +1,26 @@
<template>
<div class="flex items-center">
<div class="flex items-center gap-2">
<div
v-if="hasConflict"
v-tooltip="{
value: $t('manager.conflicts.warningTooltip'),
showDelay: 300
}"
class="flex items-center justify-center w-6 h-6 cursor-pointer"
@click="showConflictModal(true)"
>
<i class="pi pi-exclamation-triangle text-yellow-500 text-xl"></i>
</div>
<ToggleSwitch
v-if="!canToggleDirectly"
:model-value="isEnabled"
:disabled="isLoading"
:readonly="!canToggleDirectly"
aria-label="Enable or disable pack"
@focus="handleToggleInteraction"
/>
<ToggleSwitch
v-else
:model-value="isEnabled"
:disabled="isLoading"
aria-label="Enable or disable pack"
@@ -13,52 +33,96 @@
import { debounce } from 'lodash'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
InstallPackParams,
ManagerChannel,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack } = defineProps<{
const { nodePack, hasConflict } = defineProps<{
nodePack: components['schemas']['Node']
hasConflict?: boolean
}>()
const { isPackEnabled, enablePack, disablePack, installedPacks } =
useComfyManagerStore()
const { t } = useI18n()
const { isPackEnabled, enablePack, disablePack } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { showNodeConflictDialog } = useDialogService()
const { acknowledgmentState, markConflictsAsSeen } = useConflictAcknowledgment()
const isLoading = ref(false)
const isEnabled = computed(() => isPackEnabled(nodePack.id))
const version = computed(() => {
const id = nodePack.id
if (!id) return SelectedVersion.NIGHTLY
return (
installedPacks[id]?.ver ??
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
const packageConflict = computed(() =>
getConflictsForPackageByID(nodePack.id || '')
)
const canToggleDirectly = computed(() => {
return !(
hasConflict &&
!acknowledgmentState.value.modal_dismissed &&
packageConflict.value
)
})
const handleEnable = () =>
enablePack.call({
id: nodePack.id,
version: version.value,
selected_version: version.value,
repository: nodePack.repository ?? '',
channel: ManagerChannel.DEFAULT,
mode: 'default' as InstallPackParams['mode']
})
const showConflictModal = (skipModalDismissed: boolean) => {
let modal_dismissed = acknowledgmentState.value.modal_dismissed
if (skipModalDismissed) modal_dismissed = false
if (packageConflict.value && !modal_dismissed) {
showNodeConflictDialog({
conflictedPackages: [packageConflict.value],
buttonText: !isEnabled.value
? t('manager.conflicts.enableAnyway')
: t('manager.conflicts.understood'),
onButtonClick: async () => {
if (!isEnabled.value) {
await handleEnable()
}
},
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
}
}
const handleDisable = () =>
disablePack({
const handleEnable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for enabling')
}
return enablePack.call({
id: nodePack.id,
version: version.value
version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
selected_version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
repository: nodePack.repository ?? '',
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
skip_post_install: false
})
}
const handleDisable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for disabling')
}
return disablePack({
id: nodePack.id,
version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
})
}
const handleToggle = async (enable: boolean) => {
if (isLoading.value) return
@@ -67,10 +131,23 @@ const handleToggle = async (enable: boolean) => {
if (enable) {
await handleEnable()
} else {
handleDisable()
await handleDisable()
}
isLoading.value = false
}
const onToggle = debounce(handleToggle, TOGGLE_DEBOUNCE_MS, { trailing: true })
const onToggle = debounce(
(enable: boolean) => {
void handleToggle(enable)
},
TOGGLE_DEBOUNCE_MS,
{ trailing: true }
)
const handleToggleInteraction = async (event: Event) => {
if (!canToggleDirectly.value) {
event.preventDefault()
showConflictModal(false)
}
}
</script>

View File

@@ -9,8 +9,8 @@
:variant="variant"
:loading="isInstalling"
:loading-message="$t('g.installing')"
:has-warning="hasConflict"
@action="installAllPacks"
@click="onClick"
/>
</template>
@@ -18,44 +18,51 @@
import { inject, ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
IsInstallingKey,
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import {
type ConflictDetail,
type ConflictDetectionResult
} from '@/types/conflictDetectionTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']
const { nodePacks, variant, label } = defineProps<{
const { nodePacks, variant, label, hasConflict, conflictInfo } = defineProps<{
nodePacks: NodePack[]
variant?: 'default' | 'black'
label?: string
hasConflict?: boolean
conflictInfo?: ConflictDetail[]
}>()
const isInstalling = inject(IsInstallingKey, ref(false))
const onClick = (): void => {
isInstalling.value = true
}
const managerStore = useComfyManagerStore()
const { showNodeConflictDialog } = useDialogService()
const createPayload = (
installItem: NodePack
): ManagerComponents['schemas']['InstallPackParams'] => {
if (!installItem.id) {
throw new Error('Node ID is required for installation')
}
const createPayload = (installItem: NodePack) => {
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? SelectedVersion.NIGHTLY
: installItem.latest_version?.version ?? SelectedVersion.LATEST
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
: installItem.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
return {
id: installItem.id,
version: versionToInstall,
repository: installItem.repository ?? '',
channel: ManagerChannel.DEV,
mode: ManagerDatabaseSource.CACHE,
selected_version: versionToInstall,
version: versionToInstall
channel: 'dev' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: versionToInstall
}
}
@@ -65,14 +72,35 @@ const installPack = (item: NodePack) =>
const installAllPacks = async () => {
if (!nodePacks?.length) return
if (hasConflict && conflictInfo) {
const conflictedPackages: ConflictDetectionResult[] = nodePacks.map(
(pack) => ({
package_id: pack.id || '',
package_name: pack.name || '',
has_conflict: true,
conflicts: conflictInfo || [],
is_compatible: false
})
)
showNodeConflictDialog({
conflictedPackages,
buttonText: t('manager.conflicts.installAnyway'),
onButtonClick: async () => {
// Proceed with installation
isInstalling.value = true
await performInstallation(nodePacks)
}
})
return
}
// No conflicts or conflicts acknowledged - proceed with installation
isInstalling.value = true
await performInstallation(nodePacks)
}
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await Promise.all(uninstalledPacks.map(installPack))
const performInstallation = async (packs: NodePack[]) => {
await Promise.all(packs.map(installPack))
managerStore.installPack.clear()
}
</script>

View File

@@ -6,7 +6,7 @@
? $t('manager.uninstallSelected')
: $t('manager.uninstall')
"
severity="danger"
variant="red"
:loading-message="$t('manager.uninstalling')"
@action="uninstallItems"
/>
@@ -15,7 +15,6 @@
<script setup lang="ts">
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { ManagerPackInfo } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
@@ -26,16 +25,16 @@ const { nodePacks } = defineProps<{
const managerStore = useComfyManagerStore()
const createPayload = (uninstallItem: NodePack): ManagerPackInfo => {
return {
id: uninstallItem.id,
version: uninstallItem.latest_version?.version
const uninstallPack = (item: NodePack) => {
if (!item.id) {
throw new Error('Node ID is required for uninstallation')
}
return managerStore.uninstallPack({
id: item.id,
version: item.latest_version?.version ?? ''
})
}
const uninstallPack = (item: NodePack) =>
managerStore.uninstallPack(createPayload(item))
const uninstallItems = async () => {
if (!nodePacks?.length) return
await Promise.all(nodePacks.map(uninstallPack))

View File

@@ -0,0 +1,78 @@
<template>
<PackActionButton
v-bind="$attrs"
variant="black"
:label="$t('manager.updateAll')"
:loading="isUpdating"
:loading-message="$t('g.updating')"
@action="updateAllPacks"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{
nodePacks: NodePack[]
}>()
const isUpdating = ref<boolean>(false)
const managerStore = useComfyManagerStore()
const createPayload = (updateItem: NodePack) => {
return {
id: updateItem.id!,
version: updateItem.latest_version!.version!
}
}
const updatePack = async (item: NodePack) => {
if (!item.id || !item.latest_version?.version) {
console.warn('Pack missing required id or version:', item)
return
}
await managerStore.updatePack.call(createPayload(item))
}
const updateAllPacks = async () => {
if (!nodePacks?.length) {
console.warn('No packs provided for update')
return
}
isUpdating.value = true
const updatablePacks = nodePacks.filter((pack) =>
managerStore.isPackInstalled(pack.id)
)
if (!updatablePacks.length) {
console.info('No installed packs available for update')
isUpdating.value = false
return
}
console.info(`Starting update of ${updatablePacks.length} packs`)
try {
await Promise.all(updatablePacks.map(updatePack))
managerStore.updatePack.clear()
console.info('All packs updated successfully')
} catch (error) {
console.error('Pack update failed:', error)
console.error(
'Failed packs info:',
updatablePacks.map((p) => p.id)
)
} finally {
isUpdating.value = false
}
}
</script>

View File

@@ -2,20 +2,26 @@
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader :node-packs="[nodePack]" />
<InfoPanelHeader
:node-packs="[nodePack]"
:has-conflict="hasCompatibilityIssues"
/>
</div>
<div
ref="scrollContainer"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm scrollbar-hide"
>
<div class="mb-6">
<MetadataRow
v-if="isPackInstalled(nodePack.id)"
v-if="!importFailed && isPackInstalled(nodePack.id)"
:label="t('manager.filter.enabled')"
class="flex"
style="align-items: center"
>
<PackEnableToggle :node-pack="nodePack" />
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="hasCompatibilityIssues"
/>
</MetadataRow>
<MetadataRow
v-for="item in infoItems"
@@ -29,6 +35,7 @@
:status-type="
nodePack.status as components['schemas']['NodeVersionStatus']
"
:has-compatibility-issues="hasCompatibilityIssues"
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
@@ -36,7 +43,11 @@
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs :node-pack="nodePack" />
<InfoTabs
:node-pack="nodePack"
:has-compatibility-issues="hasCompatibilityIssues"
:conflict-result="conflictResult"
/>
</div>
</div>
</div>
@@ -59,9 +70,14 @@ import PackEnableToggle from '@/components/dialog/content/manager/button/PackEna
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
interface InfoItem {
key: string
@@ -75,18 +91,55 @@ const { nodePack } = defineProps<{
const scrollContainer = ref<HTMLElement | null>(null)
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack.id))
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack.id))
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
whenever(isInstalled, () => {
isInstalling.value = false
})
const { isPackInstalled } = useComfyManagerStore()
const { checkNodeCompatibility } = useConflictDetection()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { t, d, n } = useI18n()
// Check compatibility once and pass to children
const conflictResult = computed((): ConflictDetectionResult | null => {
// For installed packages, use stored conflict data
if (isInstalled.value && nodePack.id) {
return getConflictsForPackageByID(nodePack.id) || null
}
// For non-installed packages, perform compatibility check
const compatibility = checkNodeCompatibility(nodePack)
if (compatibility.hasConflict) {
return {
package_id: nodePack.id || '',
package_name: nodePack.name || '',
has_conflict: true,
conflicts: compatibility.conflicts,
is_compatible: false
}
}
return null
})
const hasCompatibilityIssues = computed(() => {
return conflictResult.value?.has_conflict
})
const packageId = computed(() => nodePack.id || '')
const { importFailed, showImportFailedDialog } =
useImportFailedDetection(packageId)
provide(ImportFailedKey, {
importFailed,
showImportFailedDialog
})
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
@@ -128,17 +181,3 @@ whenever(
{ immediate: true }
)
</script>
<style scoped>
.hidden-scrollbar {
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
</style>

View File

@@ -1,28 +1,36 @@
<template>
<div v-if="nodePacks?.length" class="flex flex-col items-center mb-6">
<div v-if="nodePacks?.length" class="flex flex-col items-center">
<slot name="thumbnail">
<PackIcon :node-pack="nodePacks[0]" width="24" height="24" />
<PackIcon :node-pack="nodePacks[0]" width="204" height="106" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">
{{ nodePacks[0].name }}
<span class="inline-block text-base">{{ nodePacks[0].name }}</span>
</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
<div
v-if="!importFailed"
class="mt-2 mb-4 w-full max-w-xs flex justify-center"
>
<slot name="install-button">
<PackUninstallButton
v-if="isAllInstalled"
v-bind="$attrs"
:node-packs="nodePacks"
/>
<PackInstallButton v-else v-bind="$attrs" :node-packs="nodePacks" />
<PackInstallButton
v-else
v-bind="$attrs"
:node-packs="nodePacks"
:has-conflict="hasConflict"
/>
</slot>
</div>
</div>
<div v-else class="flex flex-col items-center mb-6">
<div v-else class="flex flex-col items-center">
<NoResultsPlaceholder
:message="$t('manager.status.unknown')"
:title="$t('manager.tryAgainLater')"
@@ -31,7 +39,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { inject, ref, watch } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
@@ -39,13 +47,19 @@ import PackUninstallButton from '@/components/dialog/content/manager/button/Pack
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { components } from '@/types/comfyRegistryTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
const { nodePacks } = defineProps<{
const { nodePacks, hasConflict } = defineProps<{
nodePacks: components['schemas']['Node'][]
hasConflict?: boolean
}>()
const managerStore = useComfyManagerStore()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const isAllInstalled = ref(false)
watch(
[() => nodePacks, () => managerStore.installedPacks],

View File

@@ -6,8 +6,12 @@
<PackIconStacked :node-packs="nodePacks" />
</template>
<template #title>
{{ nodePacks.length }}
{{ $t('manager.packsSelected') }}
<div class="mt-5">
<span class="inline-block mr-2 text-blue-500 text-base">{{
nodePacks.length
}}</span>
<span class="text-base">{{ $t('manager.packsSelected') }}</span>
</div>
</template>
<template #install-button>
<PackInstallButton :full-width="true" :node-packs="nodePacks" />

View File

@@ -1,15 +1,31 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList>
<Tab value="description">
<TabList class="overflow-x-auto scrollbar-hide">
<Tab v-if="hasCompatibilityIssues" value="warning" class="p-2 mr-6">
<div class="flex items-center gap-1">
<span></span>
{{ importFailed ? $t('g.error') : $t('g.warning') }}
</div>
</Tab>
<Tab value="description" class="p-2 mr-6">
{{ $t('g.description') }}
</Tab>
<Tab value="nodes">
<Tab value="nodes" class="p-2">
{{ $t('g.nodes') }}
</Tab>
</TabList>
<TabPanels class="overflow-auto">
<TabPanels class="overflow-auto py-4 px-2">
<TabPanel
v-if="hasCompatibilityIssues"
value="warning"
class="bg-transparent"
>
<WarningTabPanel
:node-pack="nodePack"
:conflict-result="conflictResult"
/>
</TabPanel>
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
@@ -27,16 +43,25 @@ import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, ref } from 'vue'
import { computed, inject, ref, watchEffect } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import WarningTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/WarningTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
const { nodePack } = defineProps<{
const { nodePack, hasCompatibilityIssues, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
hasCompatibilityIssues?: boolean
conflictResult?: ConflictDetectionResult | null
}>()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const nodeNames = computed(() => {
// @ts-expect-error comfy_nodes is an Algolia-specific field
const { comfy_nodes } = nodePack
@@ -44,4 +69,17 @@ const nodeNames = computed(() => {
})
const activeTab = ref('description')
// Watch for compatibility issues and automatically switch to warning tab
watchEffect(
() => {
if (hasCompatibilityIssues) {
activeTab.value = 'warning'
} else if (activeTab.value === 'warning') {
// If currently on warning tab but no issues, switch to description
activeTab.value = 'description'
}
},
{ flush: 'post' }
)
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<div v-for="(section, index) in sections" :key="index" class="mb-4">
<div class="mb-1">
<div class="mb-3">
{{ section.title }}
</div>
<div class="text-muted break-words">

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex py-1.5 text-xs">
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}:</div>
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}</div>
<div class="w-2/3">
<slot>{{ value }}</slot>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="mt-4 overflow-hidden">
<div class="overflow-hidden">
<InfoTextSection
v-if="nodePack?.description"
:sections="descriptionSections"

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-4 mt-4 text-sm">
<div class="flex flex-col gap-4 text-sm">
<template v-if="mappedNodeDefs?.length">
<div
v-for="nodeDef in mappedNodeDefs"

View File

@@ -0,0 +1,44 @@
<template>
<div class="flex flex-col gap-3">
<button
v-if="importFailedInfo"
class="cursor-pointer outline-none border-none inline-flex items-center justify-end bg-transparent gap-1"
@click="showImportFailedDialog"
>
<i class="pi pi-code text-base"></i>
<span class="dark-theme:text-white text-sm">{{
t('serverStart.openLogs')
}}</span>
</button>
<div
v-for="(conflict, index) in conflictResult?.conflicts || []"
:key="index"
class="p-3 bg-yellow-800/20 rounded-md"
>
<div class="flex justify-between items-center">
<div class="text-sm break-words flex-1">
{{ getConflictMessage(conflict, $t) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { t } from '@/i18n'
import { components } from '@/types/comfyRegistryTypes'
import { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
const { nodePack, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
conflictResult: ConflictDetectionResult | null | undefined
}>()
const packageId = computed(() => nodePack?.id || '')
const { importFailedInfo, showImportFailedDialog } =
useImportFailedDetection(packageId)
</script>

View File

@@ -43,7 +43,7 @@
</span>
<p
v-if="nodePack.description"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden max-h-12 line-clamp-3 my-0 leading-4 mb-2 overflow-hidden"
>
{{ nodePack.description }}
</p>
@@ -119,12 +119,20 @@ provide(IsInstallingKey, isInstalling)
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !isPackEnabled(nodePack?.id)
)
const isDisabled = ref(false)
const managerStore = useComfyManagerStore()
whenever(isInstalled, () => (isInstalling.value = false))
// Watch the installedPacks object directly (which gets updated from WebSocket)
whenever(
() => managerStore.installedPacksIds,
() => {
const isInstalled = isPackInstalled(nodePack?.id)
isDisabled.value = isInstalled && !isPackEnabled(nodePack?.id)
// Update isInstalling state after installation is complete
if (isInstalling.value && isInstalled) isInstalling.value = false
}
)
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined

View File

@@ -1,13 +1,39 @@
<template>
<div
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
class="h-12 flex justify-between items-center px-4 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
</div>
<PackInstallButton v-if="!isInstalled" :node-packs="[nodePack]" />
<PackEnableToggle v-else :node-pack="nodePack" />
<div class="flex justify-end items-center gap-2">
<template v-if="importFailed">
<div
class="flex justify-center items-center gap-2 cursor-pointer"
@click="showImportFailedDialog"
>
<i class="pi pi-exclamation-triangle text-red-500 text-sm"></i>
<span class="text-red-500 text-xs pt-0.5">{{
t('manager.failedToInstall')
}}</span>
</div>
</template>
<template v-else>
<template v-if="!isInstalled">
<PackInstallButton
:node-packs="[nodePack]"
:has-conflict="uninstalledPackConflict.hasConflict"
:conflict-info="uninstalledPackConflict.conflicts"
/>
</template>
<template v-else>
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="installedPackHasConflict"
/>
</template>
</template>
</div>
</div>
</template>
@@ -17,7 +43,10 @@ import { useI18n } from 'vue-i18n'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
@@ -27,9 +56,29 @@ const { nodePack } = defineProps<{
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const { n } = useI18n()
const { n, t } = useI18n()
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { checkNodeCompatibility } = useConflictDetection()
const { importFailed, showImportFailedDialog } = useImportFailedDetection(
nodePack.id
)
const conflicts = computed(
() => getConflictsForPackageByID(nodePack.id!) || null
)
const installedPackHasConflict = computed(() => {
if (!nodePack.id) return false
return !!conflicts.value
})
const uninstalledPackConflict = computed(() => {
return checkNodeCompatibility(nodePack)
})
</script>

View File

@@ -20,7 +20,10 @@
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isInstalled">
<PackEnableToggle :node-pack="nodePack" />
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="!!packageConflicts"
/>
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
@@ -35,6 +38,7 @@ import { computed } from 'vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
@@ -42,5 +46,13 @@ const { nodePack } = defineProps<{
}>()
const { isPackInstalled } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const packageConflicts = computed(() => {
if (!nodePack.id || !isInstalled.value) return null
// For installed packages, check conflicts from store
return getConflictsForPackageByID(nodePack.id)
})
</script>

View File

@@ -1,11 +1,37 @@
<template>
<img
:src="isImageError ? DEFAULT_ICON : imgSrc"
:alt="nodePack.name + ' icon'"
class="object-contain rounded-lg max-h-72 max-w-72"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
<div class="w-full max-w-[204] aspect-[2/1] rounded-lg overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
@@ -13,29 +39,14 @@ import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_ICON = '/assets/images/fallback-gradient-avatar.svg'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '4.5rem',
height = '4.5rem'
} = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
width?: string
height?: string
}>()
const isImageError = ref(false)
const shouldShowFallback = computed(
() => !nodePack.icon || nodePack.icon.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_ICON : nodePack.icon
)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
</script>

View File

@@ -1,25 +1,19 @@
<template>
<div class="relative w-24 h-24">
<div class="relative w-[224px] h-[104px] shadow-xl">
<div
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
:key="pack.id"
class="absolute"
class="absolute w-[210px] h-[90px]"
:style="{
bottom: `${index * offset}px`,
right: `${index * offset}px`,
zIndex: maxVisible - index
}"
>
<div class="border rounded-lg p-0.5">
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
<div class="border rounded-lg shadow-lg p-0.5">
<PackIcon :node-pack="pack" />
</div>
</div>
<div
v-if="nodePacks.length > maxVisible"
class="absolute -top-2 -right-2 bg-primary rounded-full w-7 h-7 flex items-center justify-center text-xs font-bold shadow-md z-10"
>
+{{ nodePacks.length - maxVisible }}
</div>
</div>
</template>

View File

@@ -33,6 +33,10 @@
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
/>
<PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="updateAvailableNodePacks"
/>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
@@ -65,8 +69,10 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackUpdateButton from '@/components/dialog/content/manager/button/PackUpdateButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
import {
type SearchOption,
SortableAlgoliaField
@@ -83,6 +89,7 @@ const { searchResults, sortOptions } = defineProps<{
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
isUpdateAvailableTab?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery')
@@ -96,6 +103,10 @@ const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
// Use the composable to get update available nodes
const { hasUpdateAvailable, updateAvailableNodePacks } =
useUpdateAvailableNodes()
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)

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