Compare commits

..

59 Commits

Author SHA1 Message Date
Richard Yu
13056a00b1 Remove unused maskMetadataStore.ts file 2025-10-10 15:59:10 -07:00
Richard Yu
fdd6b3bcfc wip 2025-10-10 15:55:43 -07:00
Deep Roy
529a4de583 Carry over invite code param for logout action
The invite code page starts with a logout action, but we were
dropping the invite code query param. This preserves it.
2025-10-03 18:32:44 -04:00
bymyself
5c0eef8d3f [bugfix] Fix CSS import path in CloudTemplate.vue for build
- Change from alias path to relative path for fonts.css import
- Fixes build error: "ENOENT: no such file or directory" for fonts.css
2025-09-28 21:09:31 -07:00
bymyself
43db891c1a [bugfix] Fix TypeScript errors in typecheck
- Add @ts-expect-error directive to unused postSurveyStatus function in auth.ts
- Add @ts-expect-error for .mts import extension in vite.electron.config.mts
- Add @ts-expect-error directives for global variable assignments in vitest.setup.ts
- Remove vite.electron.config.mts from ESLint allowDefaultProject to fix duplicate inclusion error
2025-09-28 20:18:05 -07:00
bymyself
1b1cb956e6 Fix unused exports for knip check 2025-09-28 16:17:09 -07:00
bymyself
ff0c15b119 merge main into rh-test 2025-09-28 15:33:29 -07:00
Deep Roy
1c0f151d02 Add base url to index.html (#5732) 2025-09-23 13:50:47 -04:00
Jin Yi
2702ac64fe [bugfix] Fix cloud invite code route authentication issue (#5730)
## Summary
- Remove `requiresAuth: true` from cloud-invite-code route to fix
redirect issues in production

## Problem
The `/cloud/code/:code` route had conflicting configurations:
- Route was marked as `requiresAuth: true` in route definition
- But was treated as a public route in the router guard logic
- This caused authentication redirect issues when unauthenticated users
tried to access invite links

## Solution
Removed the `requiresAuth: true` meta property from the
cloud-invite-code route, allowing it to properly function as a public
route that redirects to login with the invite code.

## Test plan
- [x] Access `/cloud/code/TESTCODE` while logged out - should redirect
to login with `inviteCode` query param
- [x] Verify no authentication errors in console
- [x] Confirm invite code is preserved through the redirect flow

Fixes authentication redirect issue for cloud invite links

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5730-bugfix-Fix-cloud-invite-code-route-authentication-issue-2776d73d36508149b512d7f735b1a231)
by [Unito](https://www.unito.io)
2025-09-22 20:08:30 -07:00
Jin Yi
8ca541e850 feat: add email verification check for cloud onboarding (#5636)
## Summary
- Added email verification flow for new users during onboarding
- Implemented invite code claiming with proper validation 
- Updated API endpoints from `/invite/` to `/invite_code/` for
consistency

## Changes

### Email Verification
- Added `CloudVerifyEmailView` component with email verification UI
- Added email verification check after login in `CloudLoginView`
- Added `isEmailVerified` property to Firebase auth store
- Users must verify email before claiming invite codes

### Invite Code Flow
- Enhanced `CloudClaimInviteView` with full claim invite functionality
- Updated `InviteCheckView` to route users based on email verification
status
- Modified API to return both `claimed` and `expired` status for invite
codes
- Added proper error handling and Sentry logging for invite operations

### API Updates
- Changed endpoint paths from `/invite/` to `/invite_code/` 
- Updated `getInviteCodeStatus()` to return `{ claimed: boolean;
expired: boolean }`
- Updated `claimInvite()` to return `{ success: boolean; message: string
}`

### UI/UX Improvements
- Added Korean translations for all new strings
- Improved button styling and layout in survey and waitlist views
- Added proper loading states and error handling

## Test Plan
- [ ] Test new user signup flow with email verification
- [ ] Test invite code validation (expired/claimed/valid codes)
- [ ] Test email verification redirect flow
- [ ] Test invite claiming after email verification
- [ ] Verify Korean translations display correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-20 20:29:56 -07:00
bymyself
d3a5d9e995 remove old import 2025-09-18 21:27:07 -07:00
bymyself
168e885d50 expose sentry to extensions 2025-09-18 21:16:42 -07:00
Christian Byrne
504aabd097 Disable import map on cloud (#5642)
## Summary

Disabled ImportMap generation for Vue/PrimeVue dependencies to optimize
cloud deployment performance by reducing 600+ HTTP requests to 8 bundled
files.

## Changes

- **What**: Commented out [ImportMap
entries](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap)
for Vue, PrimeVue, and related packages in [Vite
configuration](https://vitejs.dev/config/)
- **Performance**: Reduced from 600+ individual files to ~8 bundled
chunks with proper compression
- **Deployment**: Improved cloud load times by eliminating excessive
HTTP requests to `static/assets/lib/` directory

## Review Focus

Temporary optimization approach and extension ecosystem compatibility.
Verify that core extensions remain functional without ImportMap-based
Vue/PrimeVue imports. Long-term solution should implement CDN cache
headers and etag for frontend version rather than disabling ImportMap
entirely.

## Context

The ImportMap plugin with `recursiveDependence: true` generates
individual files for every PrimeVue component, creating performance
bottlenecks in cloud deployment. This selective approach maintains the
ImportMap system for future extension API imports while bundling
framework dependencies normally.

## Restoration Path

To restore full ImportMap functionality, uncomment the entries in
`vite.config.mts` and verify extension compatibility before production
deployment.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5642-Disable-import-map-on-cloud-2726d73d36508116acdff66756c98473)
by [Unito](https://www.unito.io)
2025-09-18 15:13:29 -07:00
Robin Huang
c7bbab53a6 Explicitly add email scope for social auth login. (#5638)
## Summary

Some users were authenticating successfully but their email addresses
weren't being extracted from the Firebase token. This happened because
we weren't explicitly requesting the email scope during OAuth
authentication.
 
While Firebase's default configuration includes basic profile info, it
doesn't guarantee email access for all account types - particularly
Google Workspace accounts with restrictive policies or users with
privacy-conscious settings.

[Github
Scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)

## Changes

Adding email scope for Google + Github social OAuth.

## Review Focus
N/A

## Screenshots (if applicable)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5638-Explicitly-add-email-scope-for-social-auth-login-2726d73d3650817ab356fc9c04f8641b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-09-18 14:17:55 -07:00
Jin Yi
33b6df55a8 feat: Add Sentry error tracking to auth API functions (#5623)
## Summary
- Added comprehensive Sentry error tracking to all auth API functions
- Implemented helper functions to reduce code duplication  
- Properly distinguish between HTTP errors and network errors

## Changes
- Added `captureApiError` helper function for consistent error reporting
- Added `isHttpError` helper to prevent duplicate error capture
- Enhanced error tracking with:
  - Proper error type classification (`http_error` vs `network_error`)
  - HTTP status codes and response details
  - Operation names for better context
  - Route templates for better API endpoint tracking

## Test plan
- [ ] Verify auth functions work correctly in normal flow
- [ ] Test error scenarios (network failures, 4xx/5xx responses)
- [ ] Confirm Sentry receives proper error reports without duplicates
- [ ] Check that error messages are informative and actionable

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5623-feat-Add-Sentry-error-tracking-to-auth-API-functions-2716d73d3650819fbb15e73d19642235)
by [Unito](https://www.unito.io)
2025-09-18 11:39:23 -07:00
Jin Yi
16ebe33488 fix: Add validation and consistent data structure for survey 'Other' options (#5620)
## Summary
- Added validation to prevent users from proceeding when "Other" is
selected but text input is empty
- Changed data structure to send consistent string values to database
instead of mixed objects
- Both useCase and industry now send user input directly when "Other" is
selected

## Changes
- Added `useCaseOther` field and input to survey form  
- Updated `validStep2` to validate useCaseOther when useCase is 'other'
- Modified submit payload to send string values consistently for both
useCase and industry fields

## Test plan
- [x] Select "Other" for use case without filling input → Next button
disabled
- [x] Select "Other" for industry without filling input → Next button
disabled
- [x] Fill in "Other" text inputs → Next button enabled
- [x] Submit survey with "Other" selections → Payload sends user input
as string values

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5620-fix-Add-validation-and-consistent-data-structure-for-survey-Other-options-2716d73d3650811faa6efc547db14930)
by [Unito](https://www.unito.io)
2025-09-17 21:14:20 -07:00
bymyself
108ad22d82 set user ID (anonymized) in Sentry for cloud 2025-09-16 17:18:12 -07:00
bymyself
4a10017bd2 remove mixpanel from source (will move to extension hook) 2025-09-16 16:40:44 -07:00
bymyself
646d7a68be fix mixpanel people set call 2025-09-16 15:49:59 -07:00
bymyself
c13371ef47 include uid as explicit property on mixpanel profile 2025-09-16 15:42:15 -07:00
bymyself
775c856bf7 port user ID expose hook from 6786d8e to cloud 2025-09-16 15:18:18 -07:00
Christian Byrne
e035f895a3 [i18n] improve cloud onboarding translations for cultural accuracy (#5613)
Enhances cloud onboarding translations across 8 languages (es, fr, ja, ko, ru, ar, zh, zh-TW) with native-speaker quality improvements focusing on natural flow, professional terminology, and cultural appropriateness rather than literal translations.
2025-09-16 14:34:46 -07:00
Richard Yu
98e543ec31 feat: allow user to add job-id to clipboard 2025-09-15 15:03:19 -07:00
Jin Yi
992efc4486 chore: loading text to loading icon, modify the width for responsive web (#5582) 2025-09-15 00:59:50 -07:00
Jin Yi
88130a9cae feature: font modified (#5583) 2025-09-15 00:59:06 -07:00
Jin Yi
ffd2b0efab Feature/cloud reponsive (#5580)
* fix: hero title font & responsive

* chore: text center added

* chore: style modified

* chore: delete learn about button

* chore: waitlist title added
2025-09-15 00:42:39 -07:00
Christian Byrne
7c9b8bb7a6 feat: add cloudOnboarding translations for all supported languages (#5578)
* feat: add missing translations for cloud onboarding components

Replaces hardcoded text with i18n translations across cloud onboarding flow:

- CloudWaitlistView: Add translations for title lines, questions text, and contact link
- CloudClaimInviteView: Add translations for processing title and claim button
- CloudSorryContactSupportView: Add translation for error title
- CloudVerifyEmailView: Add translation for verification title
- CloudTemplateFooter: Add translation for "Need Help?" link
- CloudLoginView: Replace hardcoded "Questions? Contact us" and "here" with translations
- CloudSignupView: Replace hardcoded "Questions? Contact us" and "here" with translations
- CloudForgotPasswordView: Replace hardcoded "here" with translation
- Remove all eslint-disable @intlify/vue-i18n/no-raw-text comments
- Add proper useI18n imports to all affected components

Ensures consistent internationalization support across the entire cloud onboarding experience.

* feat: add cloudOnboarding translations for all supported languages

Adds comprehensive translations for cloud onboarding components across all supported locales:

English (en): Base translations for waitlist, claim invite, verify email, and support
Chinese Simplified (zh): 等候名单, 邀请码处理, 邮箱验证, 联系支持
Chinese Traditional (zh-TW): 等候名單, 邀請碼處理, 郵箱驗證, 聯繫支援
Japanese (ja): ウェイトリスト, 招待コード処理, メール認証, サポート連絡
Korean (ko): 대기명단, 초대코드 처리, 이메일 인증, 지원 문의
Russian (ru): Список ожидания, обработка кода приглашения, подтверждение почты
French (fr): Liste d'attente, traitement code invitation, vérification email
Spanish (es): Lista de espera, procesamiento código invitación, verificación email
Arabic (ar): قائمة الانتظار, معالجة رمز الدعوة, التحقق من البريد

Ensures consistent internationalization across the entire cloud onboarding experience for global users.

* feat: add missing privateBeta and start section translations

Adds translations for previously missing cloud onboarding text that was already using translation keys:

- privateBeta.title: "Cloud is currently in private beta" message
- privateBeta.desc: Beta signup description text
- start.title: "Start creating in seconds" header
- start.desc: "Zero setup required" subtext
- start.explain: Multiple output generation description
- start.learnAboutButton: "Learn about Cloud" button text
- start.wantToRun: Local ComfyUI option text
- start.download: "Download ComfyUI" button text

All 9 languages updated:
- Traditional Chinese: 私人測試階段, 幾秒內開始創作, 了解 Cloud
- Simplified Chinese: 私人测试阶段, 几秒内开始创作, 了解 Cloud
- Japanese: プライベートベータ版, 数秒で創作を開始, Cloudについて学ぶ
- Korean: 비공개 베타 버전, 몇 초 만에 창작 시작, Cloud에 대해 알아보기
- Russian: закрытая бета-версия, начните создавать за секунды, Узнать о Cloud
- French: bêta privée, commencez à créer en quelques secondes, En savoir plus sur Cloud
- Spanish: beta privada, comienza a crear en segundos, Aprende sobre Cloud
- Arabic: البيتا الخاصة, ابدأ الإبداع في ثوان, تعلم عن Cloud

Fixes missing translations reported during Traditional Chinese testing.

* feat: restore French translation file and add cloudOnboarding translations

- Restored src/locales/fr/main.json from clean backup to remove duplicate sections
- Added complete French translations for cloudOnboarding section
- Includes survey, waitlist, forgotPassword, privateBeta, start, and other subsections
- Structure matches English version exactly

* [feat] Refactor cloud translations to top-level keys

- Replace nested cloudOnboarding.section.key structure with flattened cloudSection_key pattern
- Add comprehensive cloud translations for all 9 supported languages (ar, en, es, fr, ja, ko, ru, zh, zh-TW)
- Update all Vue components to use new translation key structure
- Fix "Need Help?" and other missing translations across all languages
- Simplify translation maintenance and avoid JSON structure conflicts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-15 00:16:55 -07:00
Christian Byrne
18b3b11b9a feat: add error handling and timeout recovery to cloud onboarding (#5573)
Implements robust error handling and authentication timeout recovery for the cloud onboarding flow:

- Enhanced UserCheckView with VueUse useAsyncState for declarative error handling
- Added parallel API calls for better performance using Promise.all
- Implemented loading states with skeleton views and user-friendly error messages
- Added authentication timeout handling (16s) with recovery options
- Created CloudAuthTimeoutView with "Sign Out & Try Again" functionality
- Added comprehensive i18n support for error states
2025-09-14 23:20:07 -07:00
Christian Byrne
80b1c2aaf7 [feat] Redirect to login page after logout on cloud domains (#5570)
- Add router navigation to cloud-login after successful logout
- Check hostname to ensure we only redirect on cloud domains
- Preserves existing toast notification and error handling
2025-09-14 19:44:22 -07:00
Christian Byrne
a13eeaea7e [feat] Add skeleton loading states to cloud onboarding flow (#5568)
* [feat] Add skeleton loading states to cloud onboarding flow

- Create dedicated skeleton components matching exact layouts
- CloudLoginViewSkeleton for login page with beta notice, form, social buttons
- CloudSurveyViewSkeleton for multi-step survey with progress bar
- CloudWaitlistViewSkeleton for waitlist page with title and messages
- CloudClaimInviteViewSkeleton for invite claiming page
- Update UserCheckView to show contextual skeleton based on redirect destination
- Update InviteCheckView to show appropriate skeleton during loading
- Use i18n for loading text to maintain consistency

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [feat] Fix skeleton loading flow to show progressive states

- Start with simple loading text when checking user status
- Show survey skeleton while checking survey completion
- Show waitlist skeleton while checking user activation status
- Show login skeleton when redirecting to login on error
- Preserve all original comments from upstream authors
- Use progressive disclosure based on API response flow

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-14 19:40:41 -07:00
Jin Yi
59a1380f39 [feat] Cloud onboarding flow implementation (#5494)
* feature: cloud onboarding scaffolding

* fix: redirect unknown routes

* feature: cloud onboarding flow

* chore: code review

* test: api mock for test failing

* refactor: Centralize onboarding routing with dedicated check views

- Add UserCheckView to handle all user status routing decisions
- Add InviteCheckView to manage invite code validation flow
- Simplify auth.ts by removing async operations and extra complexity
- Update login/signup to always redirect through UserCheckView
- Remove distributed routing logic from all onboarding components
- Simplify router guards to delegate to check views
- Fix infinite redirect loops for non-whitelisted users
- Use window.location.href for final navigation to bypass router conflicts

Breaking changes:
- Removed claimInvite from auth.ts (moved to CloudClaimInviteView)
- Changed route names to use cloud- prefix consistently
- Simplified getMe() to synchronous function

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: delete unused file

* Revert "test: api mock for test failing"

This reverts commit 06ca56c05e.

* feature: API applied

* feature: survey view

* feature: signup / login view completed

* style: min-h-screen deleted

* feature: completed login flow

* feature: router view added

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-14 16:36:57 -07:00
Richard Yu
f1fbab6e1f remove file mapping TTL 2025-09-06 14:54:33 -07:00
Richard Yu
9bd3d5cbe6 fix: replace uuids with displaynames rather than shas 2025-09-03 22:06:12 -07:00
Richard Yu
bd48649604 update file extension list 2025-09-03 14:01:08 -07:00
Richard Yu
c6c9487c0d feat: add filename mapping to frontend to display human readable names on input nodes 2025-09-03 11:04:00 -07:00
Robin Huang
799795cf56 Add auth token to ws connection as query parameter. 2025-09-01 15:25:34 -07:00
Jennifer Weber
4899c9d25b translations for human friendly auth errors 2025-08-29 21:53:23 -07:00
Jennifer Weber
0bd3c1271d Small fixes after rebase 2025-08-29 11:10:21 -07:00
Jennifer Weber
6eb91e4aed Show signin and signup errors on form 2025-08-29 02:32:06 -07:00
Jennifer Weber
3b3071c975 Fix for maintining the new item optimization in queue store 2025-08-29 02:32:06 -07:00
Jennifer Weber
68f0275a83 Fix for history items sometimes not appearing again
New items from the history endpoint were being ignored due to the sorting based on priority, and left out of the merge
Fixed by removing that optimization so they all go through merge.
2025-08-29 02:32:06 -07:00
Jennifer Weber
a0d66bb0d7 Fix for depulicating tasks in queuestore by promptId to take into account sorting differences 2025-08-29 02:32:06 -07:00
Jennifer Weber
1292ae0f14 Add error log when templates are not found 2025-08-29 02:32:03 -07:00
Christian Byrne
8da2b304ef allow updating outputs on custom nodes and inside subgraphs (#4963) 2025-08-29 02:30:34 -07:00
Jennifer Weber
0950da0b43 Update logic for dev server url after cloud https changes
default to staging http for now
env var can be overrden for local in the .env file
2025-08-29 02:30:34 -07:00
Deep Roy
86e2b1fc61 Add analytics for workflow loading (#4966)
Needs to land after https://github.com/Comfy-Org/cloud/pull/398

## Description

- Adds a postCloudAnalytics method in `api.ts`
- Adds a workflow_loaded event
- The event contains 
  - the source (not file type, more like workflow format) one of: 
    - apiJson (I think this is the "prompt" format?)
    - graph (the richest type)
    - template: don't fully understand this but it works
- The actual data for the workflow, depends on the source type
- If available, missingModels and missingNodeTypes, so we can easily
query those

This talks to a new endpoint on the ingest server that is being added.  

## Tests
Tested manually with:
- loading an image from civitAI with missing models
- loading an image from comfy examples with no missing models
- opening a json file in the prompt format (I asked claude to generate
one - this is the format handled by the loadApiJson function)
- opening a template file (claude generated one - this is the format
handled by loadTemplateJson function)
- Testing these for both dragAndDrop and (menu --> open --> open
workflow)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4966-Add-analytics-for-workflow-loading-24e6d73d36508170acacefb3125b7017)
by [Unito](https://www.unito.io)
2025-08-29 02:30:34 -07:00
bymyself
4a612b09ed feat: Configure vite dev server for staging cloud testing
- Hardcode DEV_SERVER_COMFYUI_URL to staging cloud URL
- Enable Vue DevTools by default for better DX
- Add SSL certificate handling for all proxy endpoints
- Add optional API key support via STAGING_API_KEY env var
- Bypass multi-user auth to simulate single-user mode
- Add comments explaining the staging setup

This allows developers to test frontend changes against the staging
cloud backend by simply running npm run dev without any env configuration.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 02:30:34 -07:00
Robin Huang
4a3c3d9c97 Use hostname to determine environment. 2025-08-29 02:30:34 -07:00
Richard Yu
c3c59988f4 sort history by exec start time rather than priority 2025-08-29 02:30:34 -07:00
Richard Yu
e6d3e94a34 Add "as TaskPrompt" 2025-08-29 02:30:34 -07:00
Richard Yu
1c0c501105 update api.ts to handle prompt formats 2025-08-29 02:30:34 -07:00
Richard Yu
980b727ff8 [fix] handle cancelling pending jobs 2025-08-29 02:30:34 -07:00
Robin Huang
40c47a8e67 Fix type error. 2025-08-29 02:30:34 -07:00
Robin Huang
f0f4313afa Add 2025-08-29 02:30:34 -07:00
Robin Huang
cb5894a100 Enable sentry integrations. 2025-08-29 02:30:34 -07:00
Richard Yu
7649feb47f [feat] Update history API to v2 array format and add comprehensive tests
- Migrate from object-based to array-based history response format
- Update /history endpoint to /history_v2 with max_items parameter
- Add lazy loading of workflows via /history_v2/:prompt_id endpoint
- Implement comprehensive browser tests for history API functionality
- Add unit tests for API methods and queue store
- Update TaskItemImpl to support history workflow loading
- Add proper error handling and edge case coverage
- Follow established test patterns for better maintainability

This change improves performance by reducing initial payload size
and enables on-demand workflow loading for history items.
2025-08-29 02:30:31 -07:00
Robin Huang
c27edb7e94 Add notifications via websocket. 2025-08-29 02:25:37 -07:00
Robin Huang
23e881e220 Prevent access without login. 2025-08-29 02:25:37 -07:00
Robin Huang
c5c06b6ba8 Add client_id to query param. 2025-08-29 02:25:37 -07:00
1073 changed files with 61903 additions and 18057 deletions

View File

@@ -67,9 +67,9 @@ This is critical for better file inspection:
Use git locally for much faster analysis:
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`
1. Get list of changed files: `git diff --name-only "$BASE_SHA" > changed_files.txt`
2. Get the full diff: `git diff "$BASE_SHA" > pr_diff.txt`
3. Get detailed file changes with status: `git diff --name-status "$BASE_SHA" > file_changes.txt`
### Step 1.5: Create Analysis Cache

View File

@@ -1,30 +1,85 @@
# Create Hotfix Release
This command guides you through creating a patch/hotfix release for ComfyUI Frontend with comprehensive safety checks and human confirmations at each step.
This command creates patch/hotfix releases for ComfyUI Frontend by backporting fixes to stable core branches. It handles both automated backports (preferred) and manual cherry-picking (fallback).
**Process Overview:**
1. **Check automated backports first** (via labels)
2. **Skip to version bump** if backports already merged
3. **Manual cherry-picking** if automation failed
4. **Create patch release** with version bump
5. **Publish GitHub release** (manually uncheck "latest")
6. **Update ComfyUI requirements.txt** via PR
<task>
Create a hotfix release by cherry-picking commits or PR commits from main to a core branch: $ARGUMENTS
Create a hotfix release by backporting commits/PRs from main to a core branch: $ARGUMENTS
Expected format: Comma-separated list of commits or PR numbers
Examples:
- `abc123,def456,ghi789` (commits)
- `#1234,#5678` (PRs)
- `abc123,#1234,def456` (mixed)
- `#1234,#5678` (PRs - preferred)
- `abc123,def456` (commit hashes)
- `#1234,abc123` (mixed)
If no arguments provided, the command will help identify the correct core branch and guide you through selecting commits/PRs.
If no arguments provided, the command will guide you through identifying commits/PRs to backport.
</task>
## Prerequisites
Before starting, ensure:
- You have push access to the repository
- GitHub CLI (`gh`) is authenticated
- You're on a clean working tree
- You understand the commits/PRs you're cherry-picking
- Push access to repository
- GitHub CLI (`gh`) authenticated
- Clean working tree
- Understanding of what fixes need backporting
## Hotfix Release Process
### Step 1: Identify Target Core Branch
### Step 1: Try Automated Backports First
**Check if automated backports were attempted:**
1. **For each PR, check existing backport labels:**
```bash
gh pr view #1234 --json labels | jq -r '.labels[].name'
```
2. **If no backport labels exist, add them now:**
```bash
# Add backport labels (this triggers automated backports)
gh pr edit #1234 --add-label "needs-backport"
gh pr edit #1234 --add-label "1.24" # Replace with target version
```
3. **Check for existing backport PRs:**
```bash
# Check for backport PRs created by automation
PR_NUMBER=${ARGUMENTS%%,*} # Extract first PR number from arguments
PR_NUMBER=${PR_NUMBER#\#} # Remove # prefix
gh pr list --search "backport-${PR_NUMBER}-to" --json number,title,state,baseRefName
```
4. **Handle existing backport scenarios:**
**Scenario A: Automated backports already merged**
```bash
# Check if backport PRs were merged to core branches
gh pr list --search "backport-${PR_NUMBER}-to" --state merged
```
- If backport PRs are merged → Skip to Step 10 (Version Bump)
- **CONFIRMATION**: Automated backports completed, proceeding to version bump?
**Scenario B: Automated backport PRs exist but not merged**
```bash
# Show open backport PRs that need merging
gh pr list --search "backport-${PR_NUMBER}-to" --state open
```
- **ACTION REQUIRED**: Merge the existing backport PRs first
- Use: `gh pr merge [PR_NUMBER] --merge` for each backport PR
- After merging, return to this command and skip to Step 10 (Version Bump)
- **CONFIRMATION**: Have you merged all backport PRs? Ready to proceed to version bump?
**Scenario C: No automated backports or they failed**
- Continue to Step 2 for manual cherry-picking
- **CONFIRMATION**: Proceeding with manual cherry-picking because automation failed?
### Step 2: Identify Target Core Branch
1. Fetch the current ComfyUI requirements.txt from master branch:
```bash
@@ -36,7 +91,7 @@ Before starting, ensure:
5. Verify the core branch exists: `git ls-remote origin refs/heads/core/*`
6. **CONFIRMATION REQUIRED**: Is `core/X.Y` the correct target branch?
### Step 2: Parse and Validate Arguments
### Step 3: Parse and Validate Arguments
1. Parse the comma-separated list of commits/PRs
2. For each item:
@@ -49,7 +104,7 @@ Before starting, ensure:
- **CONFIRMATION REQUIRED**: Use merge commit or cherry-pick individual commits?
4. Validate all commit hashes exist in the repository
### Step 3: Analyze Target Changes
### Step 4: Analyze Target Changes
1. For each commit/PR to cherry-pick:
- Display commit hash, author, date
@@ -60,7 +115,7 @@ Before starting, ensure:
2. Identify potential conflicts by checking changed files
3. **CONFIRMATION REQUIRED**: Proceed with these commits?
### Step 4: Create Hotfix Branch
### Step 5: Create Hotfix Branch
1. Checkout the core branch (e.g., `core/1.23`)
2. Pull latest changes: `git pull origin core/X.Y`
@@ -69,7 +124,7 @@ Before starting, ensure:
- Example: `hotfix/1.23.4-20241120`
5. **CONFIRMATION REQUIRED**: Created branch correctly?
### Step 5: Cherry-pick Changes
### Step 6: Cherry-pick Changes
For each commit:
1. Attempt cherry-pick: `git cherry-pick <commit>`
@@ -83,7 +138,7 @@ For each commit:
- Run validation: `pnpm typecheck && pnpm lint`
4. **CONFIRMATION REQUIRED**: Cherry-pick successful and valid?
### Step 6: Create PR to Core Branch
### Step 7: Create PR to Core Branch
1. Push the hotfix branch: `git push origin hotfix/<version>-<timestamp>`
2. Create PR using gh CLI:
@@ -100,7 +155,7 @@ For each commit:
- Impact assessment
5. **CONFIRMATION REQUIRED**: PR created correctly?
### Step 7: Wait for Tests
### Step 8: Wait for Tests
1. Monitor PR checks: `gh pr checks`
2. Display test results as they complete
@@ -111,7 +166,7 @@ For each commit:
4. Wait for all required checks to pass
5. **CONFIRMATION REQUIRED**: All tests passing?
### Step 8: Merge Hotfix PR
### Step 9: Merge Hotfix PR
1. Verify all checks have passed
2. Check for required approvals
@@ -119,7 +174,7 @@ For each commit:
4. Delete the hotfix branch
5. **CONFIRMATION REQUIRED**: PR merged successfully?
### Step 9: Create Version Bump
### Step 10: Create Version Bump
1. Checkout the core branch: `git checkout core/X.Y`
2. Pull latest changes: `git pull origin core/X.Y`
@@ -131,7 +186,7 @@ For each commit:
7. Commit: `git commit -m "[release] Bump version to 1.23.5"`
8. **CONFIRMATION REQUIRED**: Version bump correct?
### Step 10: Create Release PR
### Step 11: Create Release PR
1. Push release branch: `git push origin release/1.23.5`
2. Create PR with Release label:
@@ -184,7 +239,7 @@ For each commit:
```
5. **CONFIRMATION REQUIRED**: Release PR has "Release" label?
### Step 11: Monitor Release Process
### Step 12: Monitor Release Process
1. Wait for PR checks to pass
2. **FINAL CONFIRMATION**: Ready to trigger release by merging?
@@ -199,7 +254,102 @@ For each commit:
- PyPI upload
- pnpm types publication
### Step 12: Post-Release Verification
### Step 13: Manually Publish Draft Release
**CRITICAL**: The release workflow creates a DRAFT release. You must manually publish it:
1. **Go to GitHub Releases:** https://github.com/Comfy-Org/ComfyUI_frontend/releases
2. **Find the DRAFT release** (e.g., "v1.23.5 Draft")
3. **Click "Edit release"**
4. **UNCHECK "Set as the latest release"** ⚠️ **CRITICAL**
- This prevents the hotfix from showing as "latest"
- Main branch should always be "latest release"
5. **Click "Publish release"**
6. **CONFIRMATION REQUIRED**: Draft release published with "latest" unchecked?
### Step 14: Create ComfyUI Requirements.txt Update PR
**IMPORTANT**: Create PR to update ComfyUI's requirements.txt via fork:
1. **Setup fork (if needed):**
```bash
# Check if fork already exists
if gh repo view ComfyUI --json owner | jq -r '.owner.login' | grep -q "$(gh api user --jq .login)"; then
echo "Fork already exists"
else
# Fork the ComfyUI repository
gh repo fork comfyanonymous/ComfyUI --clone=false
echo "Created fork of ComfyUI"
fi
```
2. **Clone fork and create branch:**
```bash
# Clone your fork (or use existing clone)
GITHUB_USER=$(gh api user --jq .login)
if [ ! -d "ComfyUI-fork" ]; then
gh repo clone ${GITHUB_USER}/ComfyUI ComfyUI-fork
fi
cd ComfyUI-fork
git checkout master
git pull origin master
# Create update branch
BRANCH_NAME="update-frontend-${NEW_VERSION}"
git checkout -b ${BRANCH_NAME}
```
3. **Update requirements.txt:**
```bash
# Update the version in requirements.txt
sed -i "s/comfyui-frontend-package==[0-9].*$/comfyui-frontend-package==${NEW_VERSION}/" requirements.txt
# Verify the change
grep "comfyui-frontend-package" requirements.txt
# Commit the change
git add requirements.txt
git commit -m "Bump frontend to ${NEW_VERSION}"
git push origin ${BRANCH_NAME}
```
4. **Create PR from fork:**
```bash
# Create PR using gh CLI from fork
gh pr create \
--repo comfyanonymous/ComfyUI \
--title "Bump frontend to ${NEW_VERSION}" \
--body "$(cat <<EOF
Bump frontend to ${NEW_VERSION}
\`\`\`
python main.py --front-end-version Comfy-Org/ComfyUI_frontend@${NEW_VERSION}
\`\`\`
- Diff: [Comfy-Org/ComfyUI_frontend: v${OLD_VERSION}...v${NEW_VERSION}](https://github.com/Comfy-Org/ComfyUI_frontend/compare/v${OLD_VERSION}...v${NEW_VERSION})
- PyPI Package: https://pypi.org/project/comfyui-frontend-package/${NEW_VERSION}/
- npm Types: https://www.npmjs.com/package/@comfyorg/comfyui-frontend-types/v/${NEW_VERSION}
## Changes
- Fix: [Brief description of hotfixes included]
EOF
)"
```
5. **Clean up:**
```bash
# Return to original directory
cd ..
# Keep fork directory for future updates
echo "Fork directory 'ComfyUI-fork' kept for future use"
```
6. **CONFIRMATION REQUIRED**: ComfyUI requirements.txt PR created from fork?
### Step 15: Post-Release Verification
1. Verify GitHub release:
```bash
@@ -213,12 +363,14 @@ For each commit:
```bash
pnpm view @comfyorg/comfyui-frontend-types@1.23.5
```
4. Generate release summary with:
4. Monitor ComfyUI requirements.txt PR for approval/merge
5. Generate release summary with:
- Version released
- Commits included
- Issues fixed
- Distribution status
5. **CONFIRMATION REQUIRED**: Release completed successfully?
- ComfyUI integration status
6. **CONFIRMATION REQUIRED**: Hotfix release fully completed?
## Safety Checks
@@ -240,19 +392,28 @@ If something goes wrong:
## Important Notes
- **Always try automated backports first** - This command is for when automation fails
- Core branch version will be behind main - this is expected
- The "Release" label triggers the PyPI/npm publication
- **CRITICAL**: Always uncheck "Set as latest release" for hotfix releases
- **Must create ComfyUI requirements.txt PR** - Hotfix isn't complete without it
- PR numbers must include the `#` prefix
- Mixed commits/PRs are supported but review carefully
- Always wait for full test suite before proceeding
## Expected Timeline
## Modern Workflow Context
- Step 1-3: ~10 minutes (analysis)
- Steps 4-6: ~15-30 minutes (cherry-picking)
- Step 7: ~10-20 minutes (tests)
- Steps 8-10: ~10 minutes (version bump)
- Step 11-12: ~15-20 minutes (release)
- Total: ~60-90 minutes
**Primary Backport Method:** Automated via `needs-backport` + `X.YY` labels
**This Command Usage:**
- Smart path detection - skip to version bump if backports already merged
- Fallback to manual cherry-picking only when automation fails/has conflicts
**Complete Hotfix:** Includes GitHub release publishing + ComfyUI requirements.txt integration
This process ensures a safe, verified hotfix release with multiple confirmation points and clear tracking of what changes are being released.
## Workflow Paths
- **Path A:** Backports already merged → Skip to Step 10 (Version Bump)
- **Path B:** Backport PRs need merging → Merge them → Skip to Step 10 (Version Bump)
- **Path C:** No/failed backports → Manual cherry-picking (Steps 2-9) → Version Bump (Step 10)
This process ensures a complete hotfix release with proper GitHub publishing, ComfyUI integration, and multiple safety checkpoints.

View File

@@ -4,3 +4,24 @@
# npm run format on litegraph merge (10,672 insertions, 7,327 deletions across 129 files)
c53f197de2a3e0fa66b16dedc65c131235c1c4b6
# Reorganize renderer components into domain-driven folder structure
c8a83a9caede7bdb5f8598c5492b07d08c339d49
# Domain-driven design (DDD) refactors - September 2025
# These commits reorganized the codebase into domain-driven architecture
# [refactor] Improve renderer domain organization (#5552)
6349ceee6c0a57fc7992e85635def9b6e22eaeb2
# [refactor] Improve settings domain organization (#5550)
4c8c4a1ad4f53354f700a33ea1b95262aeda2719
# [refactor] Improve workflow domain organization (#5584)
ca312fd1eab540cc4ddc0e3d244d38b3858574f0
# [refactor] Move thumbnail functionality to renderer/core domain (#5586)
e3bb29ceb8174b8bbca9e48ec7d42cd540f40efa
# [refactor] Improve updates/notifications domain organization (#5590)
27ab355f9c73415dc39f4d3f512b02308f847801

2
.gitattributes vendored
View File

@@ -13,4 +13,4 @@
# Generated files
src/types/comfyRegistryTypes.ts linguist-generated=true
src/types/generatedManagerTypes.ts linguist-generated=true
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true

View File

@@ -2,12 +2,27 @@ name: Auto Backport
on:
pull_request_target:
types: [closed]
types: [closed, labeled]
branches: [main]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to backport'
required: true
type: string
force_rerun:
description: 'Force rerun even if backports exist'
required: false
type: boolean
default: false
jobs:
backport:
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-backport')
if: >
(github.event_name == 'pull_request_target' &&
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'needs-backport')) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write
@@ -15,6 +30,35 @@ jobs:
issues: write
steps:
- name: Validate inputs for manual triggers
if: github.event_name == 'workflow_dispatch'
run: |
# Validate PR number format
if ! [[ "${{ inputs.pr_number }}" =~ ^[0-9]+$ ]]; then
echo "::error::Invalid PR number format. Must be a positive integer."
exit 1
fi
# Validate PR exists and is merged
if ! gh pr view "${{ inputs.pr_number }}" --json merged >/dev/null 2>&1; then
echo "::error::PR #${{ inputs.pr_number }} not found or inaccessible."
exit 1
fi
MERGED=$(gh pr view "${{ inputs.pr_number }}" --json merged --jq '.merged')
if [ "$MERGED" != "true" ]; then
echo "::error::PR #${{ inputs.pr_number }} is not merged. Only merged PRs can be backported."
exit 1
fi
# Validate PR has needs-backport label
if ! gh pr view "${{ inputs.pr_number }}" --json labels --jq '.labels[].name' | grep -q "needs-backport"; then
echo "::error::PR #${{ inputs.pr_number }} does not have 'needs-backport' label."
exit 1
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout repository
uses: actions/checkout@v4
with:
@@ -25,13 +69,49 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Check if backports already exist
id: check-existing
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
run: |
# Check for existing backport PRs for this PR number
EXISTING_BACKPORTS=$(gh pr list --state all --search "backport-${PR_NUMBER}-to" --json title,headRefName,baseRefName | jq -r '.[].headRefName')
if [ -z "$EXISTING_BACKPORTS" ]; then
echo "skip=false" >> $GITHUB_OUTPUT
exit 0
fi
# For manual triggers with force_rerun, proceed anyway
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.force_rerun }}" = "true" ]; then
echo "skip=false" >> $GITHUB_OUTPUT
echo "::warning::Force rerun requested - existing backports will be updated"
exit 0
fi
echo "Found existing backport PRs:"
echo "$EXISTING_BACKPORTS"
echo "skip=true" >> $GITHUB_OUTPUT
echo "::warning::Backport PRs already exist for PR #${PR_NUMBER}, skipping to avoid duplicates"
- name: Extract version labels
if: steps.check-existing.outputs.skip != 'true'
id: versions
run: |
# Extract version labels (e.g., "1.24", "1.22")
VERSIONS=""
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
for label in $(echo "$LABELS" | jq -r '.[].name'); do
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# For manual triggers, get labels from the PR
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
else
# For automatic triggers, extract from PR event
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
LABELS=$(echo "$LABELS" | jq -r '.[].name')
fi
for label in $LABELS; do
# Match version labels like "1.24" (major.minor only)
if [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
# Validate the branch exists before adding to list
@@ -52,14 +132,23 @@ jobs:
echo "Found version labels: ${VERSIONS}"
- name: Backport commits
if: steps.check-existing.outputs.skip != 'true'
id: backport
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha }}
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
run: |
FAILED=""
SUCCESS=""
# Get PR data for manual triggers
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,mergeCommit)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
else
PR_TITLE="${{ github.event.pull_request.title }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
fi
for version in ${{ steps.versions.outputs.versions }}; do
echo "::group::Backporting to core/${version}"
@@ -109,14 +198,21 @@ jobs:
fi
- name: Create PR for each successful backport
if: steps.backport.outputs.success
if: steps.check-existing.outputs.skip != 'true' && steps.backport.outputs.success
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
run: |
PR_TITLE="${{ github.event.pull_request.title }}"
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
# Get PR data for manual triggers
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
else
PR_TITLE="${{ github.event.pull_request.title }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
fi
for backport in ${{ steps.backport.outputs.success }}; do
IFS=':' read -r version branch <<< "${backport}"
@@ -141,13 +237,20 @@ jobs:
done
- name: Comment on failures
if: failure() && steps.backport.outputs.failed
if: steps.check-existing.outputs.skip != 'true' && failure() && steps.backport.outputs.failed
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit)
PR_NUMBER="${{ inputs.pr_number }}"
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
else
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
fi
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r version reason conflicts <<< "${failure}"

View File

@@ -47,6 +47,7 @@ jobs:
needs: wait-for-ci
if: needs.wait-for-ci.outputs.should-proceed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -69,19 +70,17 @@ jobs:
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@main
uses: anthropics/claude-code-action@v1.0.6
with:
label_trigger: "claude-review"
direct_prompt: |
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: 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"
claude_args: "--max-turns 256 --allowedTools '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

@@ -128,45 +128,6 @@ jobs:
echo "- Critical security patches"
echo "- Documentation updates"
- name: Create branch protection rules
if: steps.check_version.outputs.is_minor_bump == 'true' && env.branch_exists != 'true'
env:
GITHUB_TOKEN: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }}
run: |
BRANCH_NAME="${{ steps.check_version.outputs.branch_name }}"
# Create branch protection using GitHub API
echo "Setting up branch protection for $BRANCH_NAME..."
RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/branches/$BRANCH_NAME/protection" \
-d '{
"required_status_checks": {
"strict": true,
"contexts": ["lint-and-format", "test", "playwright-tests"]
},
"enforce_admins": false,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}')
HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [[ "$HTTP_CODE" -eq 200 ]] || [[ "$HTTP_CODE" -eq 201 ]]; then
echo "✅ Branch protection successfully applied"
else
echo "⚠️ Failed to apply branch protection (HTTP $HTTP_CODE)"
echo "Response: $BODY"
# Don't fail the workflow, just warn
fi
- name: Post summary
if: steps.check_version.outputs.is_minor_bump == 'true'

View File

@@ -32,11 +32,10 @@ jobs:
with:
repository: Comfy-Org/ComfyUI_frontend
path: ComfyUI_frontend
- name: Checkout ComfyUI_devtools
uses: actions/checkout@v4
with:
repository: Comfy-Org/ComfyUI_devtools
path: ComfyUI/custom_nodes/ComfyUI_devtools
- name: Copy ComfyUI_devtools from frontend repo
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Checkout custom node repository
uses: actions/checkout@v4
with:
@@ -79,7 +78,7 @@ jobs:
wait-for-it --service 127.0.0.1:8188 -t 600
working-directory: ComfyUI
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
run: pnpm exec playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Start dev server
# Run electron dev server as it is a superset of the web dev server
@@ -87,7 +86,7 @@ jobs:
run: pnpm dev:electron &
working-directory: ComfyUI_frontend
- name: Capture base i18n
run: npx tsx scripts/diff-i18n capture
run: pnpm exec tsx scripts/diff-i18n capture
working-directory: ComfyUI_frontend
- name: Update en.json
run: pnpm collect-i18n
@@ -100,7 +99,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
working-directory: ComfyUI_frontend
- name: Diff base vs updated i18n
run: npx tsx scripts/diff-i18n diff
run: pnpm exec tsx scripts/diff-i18n diff
working-directory: ComfyUI_frontend
- name: Update i18n in custom node repository
run: |

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
run: pnpm exec playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Start dev server
# Run electron dev server as it is a superset of the web dev server

View File

@@ -33,7 +33,7 @@ jobs:
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
run: pnpm exec playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Start dev server
# Run electron dev server as it is a superset of the web dev server

View File

@@ -1,4 +1,4 @@
name: PR Playwright Deploy and Comment
name: PR Playwright Deploy (Forks)
on:
workflow_run:
@@ -9,272 +9,84 @@ env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs:
deploy-reports:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed'
if: |
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository != null &&
github.event.workflow_run.repository != null &&
github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name
permissions:
pull-requests: write
actions: read
strategy:
fail-fast: false
matrix:
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Get PR info
id: pr-info
- name: Log workflow trigger info
run: |
echo "Repository: ${{ github.repository }}"
echo "Event: ${{ github.event.workflow_run.event }}"
echo "Head repo: ${{ github.event.workflow_run.head_repository.full_name || 'null' }}"
echo "Base repo: ${{ github.event.workflow_run.repository.full_name || 'null' }}"
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v4
- name: Get PR Number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return { number: null, sanitized_branch: null };
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
const pr = pullRequests[0];
const branchName = context.payload.workflow_run.head_branch;
const sanitizedBranch = branchName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-').replace(/^-|-$/g, '');
return {
number: pr.number,
sanitized_branch: sanitizedBranch
};
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Set project name
if: fromJSON(steps.pr-info.outputs.result).number != null
id: project-name
- name: Handle Test Start
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
if [ "${{ matrix.browser }}" = "chromium-0.5x" ]; then
echo "name=comfyui-playwright-chromium-0-5x" >> $GITHUB_OUTPUT
else
echo "name=comfyui-playwright-${{ matrix.browser }}" >> $GITHUB_OUTPUT
fi
echo "branch=${{ fromJSON(steps.pr-info.outputs.result).sanitized_branch }}" >> $GITHUB_OUTPUT
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting" \
"$(date -u '${{ env.DATE_FORMAT }}')"
- name: Download playwright report
if: fromJSON(steps.pr-info.outputs.result).number != null
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: playwright-report-${{ matrix.browser }}
path: playwright-report
- name: Install Wrangler
if: fromJSON(steps.pr-info.outputs.result).number != null
run: npm install -g wrangler
- name: Deploy to Cloudflare Pages (${{ matrix.browser }})
if: fromJSON(steps.pr-info.outputs.result).number != null
id: cloudflare-deploy
continue-on-error: true
run: |
# Retry logic for wrangler deploy (3 attempts)
RETRY_COUNT=0
MAX_RETRIES=3
SUCCESS=false
pattern: playwright-report-*
path: reports
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
if npx wrangler pages deploy playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then
SUCCESS=true
echo "Deployment successful on attempt $RETRY_COUNT"
else
echo "Deployment failed on attempt $RETRY_COUNT"
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
echo "Retrying in 10 seconds..."
sleep 10
fi
fi
done
if [ $SUCCESS = false ]; then
echo "All deployment attempts failed"
exit 1
fi
- name: Handle Test Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
comment-tests-starting:
runs-on: ubuntu-latest
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'requested'
permissions:
pull-requests: write
actions: read
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return null;
}
return pullRequests[0].number;
- name: Get completion time
id: completion-time
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Generate comment body for start
if: steps.pr.outputs.result != 'null'
id: comment-body-start
GITHUB_TOKEN: ${{ github.token }}
run: |
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
echo "## 🎭 Playwright Test Results" >> comment.md
echo "" >> comment.md
echo "<img alt='comfy-loading-gif' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px' style='vertical-align: middle; margin-right: 4px;' /> **Tests are starting...** " >> comment.md
echo "" >> comment.md
echo "⏰ Started at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
echo "" >> comment.md
echo "### 🚀 Running Tests" >> comment.md
echo "- 🧪 **chromium**: Running tests..." >> comment.md
echo "- 🧪 **chromium-0.5x**: Running tests..." >> comment.md
echo "- 🧪 **chromium-2x**: Running tests..." >> comment.md
echo "- 🧪 **mobile-chrome**: Running tests..." >> comment.md
echo "" >> comment.md
echo "---" >> comment.md
echo "⏱️ Please wait while tests are running across all browsers..." >> comment.md
- name: Comment PR - Tests Started
if: steps.pr.outputs.result != 'null'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body-path: comment.md
comment-tests-completed:
runs-on: ubuntu-latest
needs: deploy-reports
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed' && always()
permissions:
pull-requests: write
actions: read
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return null;
}
return pullRequests[0].number;
- name: Download all deployment info
if: steps.pr.outputs.result != 'null'
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: deployment-info-*
merge-multiple: true
path: deployment-info
- name: Get completion time
id: completion-time
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Generate comment body for completion
if: steps.pr.outputs.result != 'null'
id: comment-body-completed
run: |
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
echo "## 🎭 Playwright Test Results" >> comment.md
echo "" >> comment.md
# Check if all tests passed
ALL_PASSED=true
for file in deployment-info/*.txt; do
if [ -f "$file" ]; then
browser=$(basename "$file" .txt)
info=$(cat "$file")
exit_code=$(echo "$info" | cut -d'|' -f2)
if [ "$exit_code" != "0" ]; then
ALL_PASSED=false
break
fi
fi
done
if [ "$ALL_PASSED" = "true" ]; then
echo "✅ **All tests passed across all browsers!**" >> comment.md
else
echo "❌ **Some tests failed!**" >> comment.md
fi
echo "" >> comment.md
echo "⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
echo "" >> comment.md
echo "### 📊 Test Reports by Browser" >> comment.md
for file in deployment-info/*.txt; do
if [ -f "$file" ]; then
browser=$(basename "$file" .txt)
info=$(cat "$file")
exit_code=$(echo "$info" | cut -d'|' -f2)
url=$(echo "$info" | cut -d'|' -f3)
# Validate URLs before using them in comments
sanitized_url=$(echo "$url" | grep -E '^https://[a-z0-9.-]+\.pages\.dev(/.*)?$' || echo "INVALID_URL")
if [ "$sanitized_url" = "INVALID_URL" ]; then
echo "Invalid deployment URL detected: $url"
url="#" # Use safe fallback
fi
if [ "$exit_code" = "0" ]; then
status="✅"
else
status="❌"
fi
echo "- $status **$browser**: [View Report]($url)" >> comment.md
fi
done
echo "" >> comment.md
echo "---" >> comment.md
if [ "$ALL_PASSED" = "true" ]; then
echo "🎉 Your tests are passing across all browsers!" >> comment.md
else
echo "⚠️ Please check the test reports for details on failures." >> comment.md
fi
- name: Comment PR - Tests Complete
if: steps.pr.outputs.result != 'null'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body-path: comment.md
# Rename merged report if exists
[ -d "reports/playwright-report-chromium-merged" ] && \
mv reports/playwright-report-chromium-merged reports/playwright-report-chromium
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -0,0 +1,139 @@
name: Publish Frontend Types
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g., 1.26.7)'
required: true
type: string
dist_tag:
description: 'npm dist-tag to use'
required: true
default: latest
type: string
ref:
description: 'Git ref to checkout (commit SHA, tag, or branch)'
required: false
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: latest
ref:
required: false
type: string
concurrency:
group: publish-frontend-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
cancel-in-progress: false
jobs:
publish_types_manual:
name: Publish @comfyorg/comfyui-frontend-types
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate inputs
shell: bash
run: |
set -euo pipefail
VERSION="${{ inputs.version }}"
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
exit 1
fi
- name: Determine ref to checkout
id: resolve_ref
shell: bash
run: |
set -euo pipefail
REF="${{ inputs.ref }}"
VERSION="${{ inputs.version }}"
if [ -n "$REF" ]; then
if ! git check-ref-format --allow-onelevel "$REF"; then
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
else
echo "ref=refs/tags/v$VERSION" >> "$GITHUB_OUTPUT"
fi
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 'lts/*'
cache: 'pnpm'
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Build types
run: pnpm build:types
- name: Verify version matches input
id: verify
shell: bash
run: |
PKG_VERSION=$(node -p "require('./package.json').version")
TYPES_PKG_VERSION=$(node -p "require('./dist/package.json').version")
if [ "$PKG_VERSION" != "${{ inputs.version }}" ]; then
echo "Error: package.json version $PKG_VERSION does not match input ${{ inputs.version }}" >&2
exit 1
fi
if [ "$TYPES_PKG_VERSION" != "${{ inputs.version }}" ]; then
echo "Error: dist/package.json version $TYPES_PKG_VERSION does not match input ${{ inputs.version }}" >&2
exit 1
fi
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
- name: Check if version already on npm
id: check_npm
shell: bash
run: |
set -euo pipefail
NAME=$(node -p "require('./dist/package.json').name")
VER="${{ steps.verify.outputs.version }}"
STATUS=0
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
else
if echo "$OUTPUT" | grep -q "E404"; then
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "::error title=Registry lookup failed::$OUTPUT" >&2
exit "$STATUS"
fi
fi
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
run: pnpm publish --access public --tag "${{ inputs.dist_tag }}" --no-git-checks
working-directory: dist
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -18,7 +18,7 @@ jobs:
is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
@@ -73,7 +73,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
@@ -126,34 +126,8 @@ jobs:
publish_types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
registry-url: https://registry.npmjs.org
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
tsconfig.tsbuildinfo
dist
key: types-tools-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
types-tools-cache-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
- run: pnpm build:types
- name: Publish package
run: pnpm publish --access public
working-directory: dist
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
uses: ./.github/workflows/publish-frontend-types.yaml
with:
version: ${{ needs.build.outputs.version }}
ref: ${{ github.event.pull_request.merge_commit_sha }}
secrets: inherit

View File

@@ -19,11 +19,11 @@ jobs:
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
run: pnpm exec playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Run Playwright tests and update snapshots
id: playwright-tests
run: npx playwright test --update-snapshots
run: pnpm exec playwright test --update-snapshots
continue-on-error: true
working-directory: ComfyUI_frontend
- uses: actions/upload-artifact@v4

View File

@@ -27,12 +27,10 @@ jobs:
repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend'
- name: Checkout ComfyUI_devtools
uses: actions/checkout@v4
with:
repository: 'Comfy-Org/ComfyUI_devtools'
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
- name: Copy ComfyUI_devtools from frontend repo
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -150,7 +148,7 @@ jobs:
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: npx playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
working-directory: ComfyUI_frontend
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ../blob-report
@@ -229,7 +227,13 @@ jobs:
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
run: |
# Run tests with both HTML and JSON reporters
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright test --project=${{ matrix.browser }} \
--reporter=list \
--reporter=html \
--reporter=json
working-directory: ComfyUI_frontend
- uses: actions/upload-artifact@v4
@@ -275,7 +279,12 @@ jobs:
merge-multiple: true
- name: Merge into HTML Report
run: npx playwright merge-reports --reporter html ./all-blob-reports
run: |
# Generate HTML report
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
# Generate JSON report separately with explicit output path
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
working-directory: ComfyUI_frontend
- name: Upload HTML report
@@ -284,3 +293,65 @@ jobs:
name: playwright-report-chromium
path: ComfyUI_frontend/playwright-report/
retention-days: 30
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get start time
id: start-time
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"${{ steps.start-time.outputs.time }}"
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all playwright reports
uses: actions/download-artifact@v4
with:
pattern: playwright-report-*
path: reports
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
- name: Deploy reports and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
run: |
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"completed"
#### END Deployment and commenting (non-forked PRs only)

View File

@@ -40,7 +40,7 @@ jobs:
- name: Get new version
id: get-version
run: |
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].version')
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].dependencies."@comfyorg/comfyui-electron-types".version')
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request

View File

@@ -68,7 +68,7 @@ jobs:
- name: Generate Manager API types
run: |
echo "Generating TypeScript types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}..."
npx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
pnpm dlx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
- name: Validate generated types
run: |

View File

@@ -68,7 +68,7 @@ jobs:
- name: Generate API types
run: |
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
npx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts
- name: Validate generated types
run: |

8
.gitignore vendored
View File

@@ -22,6 +22,8 @@ dist-ssr
*.local
# Claude configuration
.claude/*.local.json
.claude/*.local.md
.claude/*.local.txt
CLAUDE.local.md
# Editor directories and files
@@ -44,6 +46,7 @@ components.d.ts
tests-ui/data/*
tests-ui/ComfyUI_examples
tests-ui/workflows/examples
coverage/
# Browser tests
/test-results/
@@ -51,6 +54,7 @@ tests-ui/workflows/examples
/blob-report/
/playwright/.cache/
browser_tests/**/*-win32.png
browser_tests/local/
.env
@@ -77,8 +81,8 @@ vite.config.mts.timestamp-*.mjs
*storybook.log
storybook-static
# MCP Servers
.playwright-mcp/*
.nx/cache
.nx/workspace-data

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
npx lint-staged
npx tsx scripts/check-unused-i18n-keys.ts
pnpm exec lint-staged
pnpm exec tsx scripts/check-unused-i18n-keys.ts

5
.husky/pre-push Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Run Knip with cache via package script
pnpm knip

View File

@@ -9,7 +9,7 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

View File

@@ -1,12 +0,0 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"]
},
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"]
}
}
}

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
ignore-workspace-root-check=true

View File

@@ -15,26 +15,37 @@ const config: StorybookConfig = {
async viteFinal(config) {
// Use dynamic import to avoid CJS deprecation warning
const { mergeConfig } = await import('vite')
const { default: tailwindcss } = await import('@tailwindcss/vite')
// Filter out any plugins that might generate import maps
if (config.plugins) {
config.plugins = config.plugins.filter((plugin: any) => {
if (plugin && plugin.name && plugin.name.includes('import-map')) {
return false
}
return true
})
config.plugins = config.plugins
// Type guard: ensure we have valid plugin objects with names
.filter(
(plugin): plugin is NonNullable<typeof plugin> & { name: string } => {
return (
plugin !== null &&
plugin !== undefined &&
typeof plugin === 'object' &&
'name' in plugin &&
typeof plugin.name === 'string'
)
}
)
// Business logic: filter out import-map plugins
.filter((plugin) => !plugin.name.includes('import-map'))
}
return mergeConfig(config, {
// Replace plugins entirely to avoid inheritance issues
plugins: [
// Only include plugins we explicitly need for Storybook
tailwindcss(),
Icons({
compiler: 'vue3',
customCollections: {
comfy: FileSystemIconLoader(
process.cwd() + '/src/assets/icons/custom'
process.cwd() + '/packages/design-system/src/icons'
)
}
}),

View File

@@ -57,9 +57,8 @@
/* Override Storybook's problematic & selector styles */
/* Reset only the specific properties that Storybook injects */
#storybook-root li+li,
#storybook-docs li+li {
margin: inherit;
padding: inherit;
li+li {
margin: 0;
padding: revert-layer;
}
</style>

View File

@@ -1,7 +1,7 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import { setup } from '@storybook/vue3'
import type { Preview } from '@storybook/vue3-vite'
import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -9,11 +9,9 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import '../src/assets/css/style.css'
import { i18n } from '../src/i18n'
import '../src/lib/litegraph/public/css/litegraph.css'
import { useWidgetStore } from '../src/stores/widgetStore'
import { useColorPaletteStore } from '../src/stores/workspace/colorPaletteStore'
import '@/assets/css/style.css'
import { i18n } from '@/i18n'
import '@/lib/litegraph/public/css/litegraph.css'
const ComfyUIPreset = definePreset(Aura, {
semantic: {
@@ -25,13 +23,11 @@ const ComfyUIPreset = definePreset(Aura, {
// Setup Vue app for Storybook
setup((app) => {
app.directive('tooltip', Tooltip)
// Create Pinia instance
const pinia = createPinia()
app.use(pinia)
// Initialize stores
useColorPaletteStore(pinia)
useWidgetStore(pinia)
app.use(i18n)
app.use(PrimeVue, {
theme: {
@@ -50,8 +46,8 @@ setup((app) => {
app.use(ToastService)
})
// Dark theme decorator
export const withTheme = (Story: any, context: any) => {
// Theme and dialog decorator
export const withTheme = (Story: StoryFn, context: StoryContext) => {
const theme = context.globals.theme || 'light'
// Apply theme class to document root
@@ -63,7 +59,7 @@ export const withTheme = (Story: any, context: any) => {
document.body.classList.remove('dark-theme')
}
return Story()
return Story(context.args, context)
}
const preview: Preview = {

View File

@@ -1,17 +1,61 @@
# Admins
* @Comfy-Org/comfy_frontend_devs
# Desktop/Electron
/src/types/desktop/ @webfiltered
/src/constants/desktopDialogs.ts @webfiltered
/src/constants/desktopMaintenanceTasks.ts @webfiltered
/src/stores/electronDownloadStore.ts @webfiltered
/src/extensions/core/electronAdapter.ts @webfiltered
/src/views/DesktopDialogView.vue @webfiltered
/src/components/install/ @webfiltered
/src/components/maintenance/ @webfiltered
/vite.electron.config.mts @webfiltered
# Maintainers
*.md @Comfy-Org/comfy_maintainer
/tests-ui/ @Comfy-Org/comfy_maintainer
/browser_tests/ @Comfy-Org/comfy_maintainer
/.env_example @Comfy-Org/comfy_maintainer
# Common UI Components
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
# Translations (AIGODLIKE team + shinshin86)
/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer
# Topbar
/src/components/topbar/ @pythongosssss
# Load 3D extension
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss
# Mask Editor extension
/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @Comfy-Org/comfy_frontend_devs
# Legacy UI
/scripts/ui/ @pythongosssss
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88
# Assets
/src/platform/assets/ @arjansingh
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/components/load3d/ @jtydhr88
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Translations
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer

View File

@@ -265,9 +265,9 @@ The project supports three types of icons, all with automatic imports (no manual
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i-lucide:settings />`, `<i-mdi:folder />`
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `src/assets/icons/custom/` and processed by `build/customIconCollection.ts` with automatic validation.
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `packages/design-system/src/icons/` and processed by `packages/design-system/src/iconCollection.ts` with automatic validation.
For detailed instructions and code examples, see [src/assets/icons/README.md](src/assets/icons/README.md).
For detailed instructions and code examples, see [packages/design-system/src/icons/README.md](packages/design-system/src/icons/README.md).
## Working with litegraph.js

View File

@@ -16,15 +16,20 @@ Without this flag, parallel tests will conflict and fail randomly.
### ComfyUI devtools
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
ComfyUI_devtools is now included in this repository under `tools/devtools/`. During CI/CD, these files are automatically copied to the `custom_nodes` directory.
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
For local development, copy the devtools files to your ComfyUI installation:
```bash
cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
```
### 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
pnpm exec playwright install chromium --with-deps
```
### Environment Configuration
@@ -51,14 +56,6 @@ 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.
@@ -76,7 +73,7 @@ For tests that specifically need to test release functionality, see the example
**Always use UI mode for development:**
```bash
npx playwright test --ui
pnpm exec playwright test --ui
```
UI mode features:
@@ -92,8 +89,8 @@ UI mode features:
For CI or headless testing:
```bash
npx playwright test # Run all tests
npx playwright test widget.spec.ts # Run specific test file
pnpm exec playwright test # Run all tests
pnpm exec playwright test widget.spec.ts # Run specific test file
```
### Local Development Config
@@ -389,7 +386,7 @@ export default defineConfig({
Option 2 - Generate local baselines for comparison:
```bash
npx playwright test --update-snapshots
pnpm exec playwright test --update-snapshots
```
### Creating New Screenshot Baselines

View File

@@ -0,0 +1 @@
{"id":"4412323e-2509-4258-8abc-68ddeea8f9e1","revision":0,"last_node_id":39,"last_link_id":29,"nodes":[{"id":37,"type":"KSampler","pos":[3635.923095703125,870.237548828125],"size":[428,437],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"model","name":"model","type":"MODEL","link":null},{"localized_name":"positive","name":"positive","type":"CONDITIONING","link":null},{"localized_name":"negative","name":"negative","type":"CONDITIONING","link":null},{"localized_name":"latent_image","name":"latent_image","type":"LATENT","link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null},{"localized_name":"steps","name":"steps","type":"INT","widget":{"name":"steps"},"link":null},{"localized_name":"cfg","name":"cfg","type":"FLOAT","widget":{"name":"cfg"},"link":null},{"localized_name":"sampler_name","name":"sampler_name","type":"COMBO","widget":{"name":"sampler_name"},"link":null},{"localized_name":"scheduler","name":"scheduler","type":"COMBO","widget":{"name":"scheduler"},"link":null},{"localized_name":"denoise","name":"denoise","type":"FLOAT","widget":{"name":"denoise"},"link":null}],"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]},{"id":38,"type":"VAEDecode","pos":[4164.01611328125,925.5230712890625],"size":[193.25,107],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"samples","name":"samples","type":"LATENT","link":null},{"localized_name":"vae","name":"vae","type":"VAE","link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":null}],"properties":{"Node name for S&R":"VAEDecode"}},{"id":39,"type":"CLIPTextEncode","pos":[3259.289794921875,927.2508544921875],"size":[239.9375,155],"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":null}],"outputs":[{"localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":null}],"properties":{"Node name for S&R":"CLIPTextEncode"},"widgets_values":[""]}],"links":[],"groups":[],"config":{},"extra":{"ds":{"scale":1.1576250000000001,"offset":[-2808.366467322067,-478.34316506594797]}},"version":0.4}

View File

@@ -10,7 +10,7 @@ import type { Position } from './types'
* - {@link Mouse.move}
* - {@link Mouse.up}
*/
export interface DragOptions {
interface DragOptions {
button?: 'left' | 'right' | 'middle'
clickCount?: number
steps?: number

View File

@@ -5,13 +5,14 @@ import dotenv from 'dotenv'
import * as fs from 'fs'
import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph'
import type { NodeId } from '../../src/schemas/comfyWorkflowSchema'
import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema'
import type { KeyCombo } from '../../src/schemas/keyBindingSchema'
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import { ComfyActionbar } from '../helpers/actionbar'
import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { SettingDialog } from './components/SettingDialog'
import {
@@ -144,6 +145,7 @@ export class ComfyPage {
public readonly templates: ComfyTemplates
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -172,6 +174,7 @@ export class ComfyPage {
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
}
convertLeafToContent(structure: FolderStructure): FolderStructure {
@@ -453,6 +456,32 @@ export class ComfyPage {
await workflowsTab.close()
}
/**
* Attach a screenshot to the test report.
* By default, screenshots are only taken in non-CI environments.
* @param name - Name for the screenshot attachment
* @param options - Optional configuration
* @param options.runInCI - Whether to take screenshot in CI (default: false)
* @param options.fullPage - Whether to capture full page (default: false)
*/
async attachScreenshot(
name: string,
options: { runInCI?: boolean; fullPage?: boolean } = {}
) {
const { runInCI = false, fullPage = false } = options
// Skip in CI unless explicitly requested
if (process.env.CI && !runInCI) {
return
}
const testInfo = comfyPageFixture.info()
await testInfo.attach(name, {
body: await this.page.screenshot({ fullPage }),
contentType: 'image/png'
})
}
async resetView() {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
@@ -1395,7 +1424,7 @@ export class ComfyPage {
}
async closeDialog() {
await this.page.locator('.p-dialog-close-button').click()
await this.page.locator('.p-dialog-close-button').click({ force: true })
await expect(this.page.locator('.p-dialog')).toBeHidden()
}
@@ -1614,7 +1643,7 @@ export const comfyPageFixture = base.extend<{
try {
await comfyPage.setupSettings({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.UseNewMenu': 'Top',
// Hide canvas menu/info/selection toolbox by default.
'Comfy.Graph.CanvasInfo': false,
'Comfy.Graph.CanvasMenu': false,

View File

@@ -1,4 +1,5 @@
import { Page, test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import { test as base } from '@playwright/test'
export class UserSelectPage {
constructor(

View File

@@ -0,0 +1,117 @@
/**
* Vue Node Test Helpers
*/
import type { Locator, Page } from '@playwright/test'
export class VueNodeHelpers {
constructor(private page: Page) {}
/**
* Get locator for all Vue node components in the DOM
*/
get nodes(): Locator {
return this.page.locator('[data-node-id]')
}
/**
* Get locator for selected Vue node components (using visual selection indicators)
*/
get selectedNodes(): Locator {
return this.page.locator(
'[data-node-id].outline-black, [data-node-id].outline-white'
)
}
/**
* Get locator for a Vue node by the node's title (displayed name in the header)
*/
getNodeByTitle(title: string): Locator {
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
}
/**
* Get total count of Vue nodes in the DOM
*/
async getNodeCount(): Promise<number> {
return await this.nodes.count()
}
/**
* Get count of selected Vue nodes
*/
async getSelectedNodeCount(): Promise<number> {
return await this.selectedNodes.count()
}
/**
* Get all Vue node IDs currently in the DOM
*/
async getNodeIds(): Promise<string[]> {
return await this.nodes.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute('data-node-id'))
.filter((id): id is string => id !== null)
)
}
/**
* Select a specific Vue node by ID
*/
async selectNode(nodeId: string): Promise<void> {
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
}
/**
* Select multiple Vue nodes by IDs using Ctrl+click
*/
async selectNodes(nodeIds: string[]): Promise<void> {
if (nodeIds.length === 0) return
// Select first node normally
await this.selectNode(nodeIds[0])
// Add additional nodes with Ctrl+click
for (let i = 1; i < nodeIds.length; i++) {
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
modifiers: ['Control']
})
}
}
/**
* Clear all selections by clicking empty space
*/
async clearSelection(): Promise<void> {
await this.page.mouse.click(50, 50)
}
/**
* Delete selected Vue nodes using Delete key
*/
async deleteSelected(): Promise<void> {
await this.page.locator('#graph-canvas').focus()
await this.page.keyboard.press('Delete')
}
/**
* Delete selected Vue nodes using Backspace key
*/
async deleteSelectedWithBackspace(): Promise<void> {
await this.page.locator('#graph-canvas').focus()
await this.page.keyboard.press('Backspace')
}
/**
* Wait for Vue nodes to be rendered
*/
async waitForNodes(expectedCount?: number): Promise<void> {
if (expectedCount !== undefined) {
await this.page.waitForFunction(
(count) => document.querySelectorAll('[data-node-id]').length >= count,
expectedCount
)
} else {
await this.page.waitForSelector('[data-node-id]')
}
}
}

View File

@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}

View File

@@ -1,6 +1,6 @@
import { Page } from '@playwright/test'
import type { Page } from '@playwright/test'
import { ComfyPage } from '../ComfyPage'
import type { ComfyPage } from '../ComfyPage'
export class SettingDialog {
constructor(

View File

@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
class SidebarTab {
constructor(

View File

@@ -1,7 +1,14 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
export class Topbar {
constructor(public readonly page: Page) {}
private readonly menuLocator: Locator
private readonly menuTrigger: Locator
constructor(public readonly page: Page) {
this.menuLocator = page.locator('.comfy-command-menu')
this.menuTrigger = page.locator('.comfyui-logo-wrapper')
}
async getTabNames(): Promise<string[]> {
return await this.page
@@ -15,10 +22,33 @@ export class Topbar {
.innerText()
}
getMenuItem(itemLabel: string): Locator {
/**
* Get a menu item by its label, optionally within a specific parent container
*/
getMenuItem(itemLabel: string, parent?: Locator): Locator {
if (parent) {
return parent.locator(`.p-tieredmenu-item:has-text("${itemLabel}")`)
}
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
/**
* Get the visible submenu (last visible submenu in case of nested menus)
*/
getVisibleSubmenu(): Locator {
return this.page.locator('.p-tieredmenu-submenu:visible').last()
}
/**
* Check if a menu item has an active checkmark
*/
async isMenuItemActive(menuItem: Locator): Promise<boolean> {
const checkmark = menuItem.locator('.pi-check')
const classes = await checkmark.getAttribute('class')
return classes ? !classes.includes('invisible') : false
}
getWorkflowTab(tabName: string): Locator {
return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
@@ -66,10 +96,50 @@ export class Topbar {
async openTopbarMenu() {
await this.page.waitForTimeout(1000)
await this.page.locator('.comfyui-logo-wrapper').click()
const menu = this.page.locator('.comfy-command-menu')
await menu.waitFor({ state: 'visible' })
return menu
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
return this.menuLocator
}
/**
* Close the topbar menu by clicking outside
*/
async closeTopbarMenu() {
await this.page.locator('body').click({ position: { x: 10, y: 10 } })
await expect(this.menuLocator).not.toBeVisible()
}
/**
* Navigate to a submenu by hovering over a menu item
*/
async openSubmenu(menuItemLabel: string): Promise<Locator> {
const menuItem = this.getMenuItem(menuItemLabel)
await menuItem.hover()
const submenu = this.getVisibleSubmenu()
await submenu.waitFor({ state: 'visible' })
return submenu
}
/**
* Get theme menu items and interact with theme switching
*/
async getThemeMenuItems() {
const themeSubmenu = await this.openSubmenu('Theme')
return {
submenu: themeSubmenu,
darkTheme: this.getMenuItem('Dark (Default)', themeSubmenu),
lightTheme: this.getMenuItem('Light', themeSubmenu)
}
}
/**
* Switch to a specific theme
*/
async switchTheme(theme: 'dark' | 'light') {
const { darkTheme, lightTheme } = await this.getThemeMenuItems()
const themeItem = theme === 'dark' ? darkTheme : lightTheme
const themeLabel = themeItem.locator('.p-menubar-item-label')
await themeLabel.click()
}
async triggerTopbarCommand(path: string[]) {
@@ -79,9 +149,7 @@ export class Topbar {
const menu = await this.openTopbarMenu()
const tabName = path[0]
const topLevelMenuItem = this.page.locator(
`.p-menubar-item-label:text-is("${tabName}")`
)
const topLevelMenuItem = this.getMenuItem(tabName)
const topLevelMenu = menu
.locator('.p-tieredmenu-item')
.filter({ has: topLevelMenuItem })

View File

@@ -1,6 +1,6 @@
import type { Page } from '@playwright/test'
import type { NodeId } from '../../../src/schemas/comfyWorkflowSchema'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '../../helpers/manageGroupNode'
import type { ComfyPage } from '../ComfyPage'
import type { Position, Size } from '../types'
@@ -134,7 +134,7 @@ export class SubgraphSlotReference {
}
}
export class NodeSlotReference {
class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly index: number,
@@ -201,7 +201,7 @@ export class NodeSlotReference {
}
}
export class NodeWidgetReference {
class NodeWidgetReference {
constructor(
readonly index: number,
readonly node: NodeReference

View File

@@ -34,17 +34,23 @@ const getContentType = (filename: string, fileType: OutputFileType) => {
}
const setQueueIndex = (task: TaskItem) => {
task.prompt[0] = TaskHistory.queueIndex++
task.prompt.priority = TaskHistory.queueIndex++
}
const setPromptId = (task: TaskItem) => {
task.prompt[1] = uuidv4()
if (!task.prompt.prompt_id || task.prompt.prompt_id === 'prompt-id') {
task.prompt.prompt_id = uuidv4()
}
}
export default class TaskHistory {
static queueIndex = 0
static readonly defaultTask: Readonly<HistoryTaskItem> = {
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
prompt: {
priority: 0,
prompt_id: 'prompt-id',
extra_data: { client_id: uuidv4() }
},
outputs: {},
status: {
status_str: 'success',
@@ -66,10 +72,37 @@ export default class TaskHistory {
)
private async handleGetHistory(route: Route) {
const url = route.request().url()
// Handle history_v2/:prompt_id endpoint
const promptIdMatch = url.match(/history_v2\/([^?]+)/)
if (promptIdMatch) {
const promptId = promptIdMatch[1]
const task = this.tasks.find((t) => t.prompt.prompt_id === promptId)
const response: Record<string, any> = {}
if (task) {
response[promptId] = task
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
// Handle history_v2 list endpoint
// Convert HistoryTaskItem to RawHistoryItem format expected by API
const rawHistoryItems = this.tasks.map((task) => ({
prompt_id: task.prompt.prompt_id,
prompt: task.prompt,
status: task.status,
outputs: task.outputs,
...(task.meta && { meta: task.meta })
}))
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.tasks)
body: JSON.stringify({ history: rawHistoryItems })
})
}
@@ -93,7 +126,7 @@ export default class TaskHistory {
async setupRoutes() {
return this.comfyPage.page.route(
/.*\/api\/(view|history)(\?.*)?$/,
/.*\/api\/(view|history_v2)(\/[^?]*)?(\?.*)?$/,
async (route) => {
const request = route.request()
const method = request.method()

View File

@@ -12,9 +12,10 @@ export const webSocketFixture = base.extend<{
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = ((window as any).__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor() {
// @ts-expect-error
super(...arguments)
constructor(
...rest: ConstructorParameters<typeof window.WebSocket>
) {
super(...rest)
store[this.url] = this
}
}

View File

@@ -1,4 +1,4 @@
import { FullConfig } from '@playwright/test'
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { backupPath } from './utils/backupUtils'

View File

@@ -1,4 +1,4 @@
import { FullConfig } from '@playwright/test'
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { restorePath } from './utils/backupUtils'

View File

@@ -0,0 +1,104 @@
import type { ReadOnlyRect } from '../../src/lib/litegraph/src/interfaces'
import type { ComfyPage } from '../fixtures/ComfyPage'
interface FitToViewOptions {
selectionOnly?: boolean
zoom?: number
padding?: number
}
/**
* Instantly fits the canvas view to graph content without waiting for UI animation.
*
* Lives outside the shared fixture to keep the default ComfyPage interactions user-oriented.
*/
export async function fitToViewInstant(
comfyPage: ComfyPage,
options: FitToViewOptions = {}
) {
const { selectionOnly = false, zoom = 0.75, padding = 10 } = options
const rectangles = await comfyPage.page.evaluate<
ReadOnlyRect[] | null,
{ selectionOnly: boolean }
>(
({ selectionOnly }) => {
const app = window['app']
if (!app?.canvas) return null
const canvas = app.canvas
const items = (() => {
if (selectionOnly && canvas.selectedItems?.size) {
return Array.from(canvas.selectedItems)
}
try {
return Array.from(canvas.positionableItems ?? [])
} catch {
return []
}
})()
if (!items.length) return null
const rects: ReadOnlyRect[] = []
for (const item of items) {
const rect = item?.boundingRect
if (!rect) continue
const x = Number(rect[0])
const y = Number(rect[1])
const width = Number(rect[2])
const height = Number(rect[3])
rects.push([x, y, width, height] as const)
}
return rects.length ? rects : null
},
{ selectionOnly }
)
if (!rectangles || rectangles.length === 0) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const [x, y, width, height] of rectangles) {
minX = Math.min(minX, Number(x))
minY = Math.min(minY, Number(y))
maxX = Math.max(maxX, Number(x) + Number(width))
maxY = Math.max(maxY, Number(y) + Number(height))
}
const hasFiniteBounds =
Number.isFinite(minX) &&
Number.isFinite(minY) &&
Number.isFinite(maxX) &&
Number.isFinite(maxY)
if (!hasFiniteBounds) return
const bounds: ReadOnlyRect = [
minX - padding,
minY - padding,
maxX - minX + 2 * padding,
maxY - minY + 2 * padding
]
await comfyPage.page.evaluate(
({ bounds, zoom }) => {
const app = window['app']
if (!app?.canvas) return
const canvas = app.canvas
canvas.ds.fitToBounds(bounds, { zoom })
canvas.setDirty(true, true)
},
{ bounds, zoom }
)
await comfyPage.nextFrame()
}

View File

@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ManageGroupNode {
footer: Locator

View File

@@ -1,10 +1,10 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import path from 'path'
import {
import type {
TemplateInfo,
WorkflowTemplates
} from '../../src/types/workflowTemplateTypes'
} from '../../src/platform/workflow/templates/types/template'
export class ComfyTemplates {
readonly content: Locator

View File

@@ -29,9 +29,9 @@ test.describe('Actionbar', () => {
// Intercept the prompt queue endpoint
let promptNumber = 0
comfyPage.page.route('**/api/prompt', async (route, req) => {
await comfyPage.page.route('**/api/prompt', async (route, req) => {
await new Promise((r) => setTimeout(r, 100))
route.fulfill({
await route.fulfill({
status: 200,
body: JSON.stringify({
prompt_id: promptNumber,

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Background Image Upload', () => {
test.beforeEach(async ({ comfyPage }) => {
// Reset the background image setting before each test

View File

@@ -1,5 +1,5 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
ComfyPage,
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
@@ -15,6 +15,10 @@ async function afterChange(comfyPage: ComfyPage) {
})
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Change Tracker', () => {
test.describe('Undo/Redo', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,7 +1,12 @@
import { Page, expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
interface ChatHistoryEntry {
prompt: string
response: string

View File

@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
import type { Palette } from '../../src/schemas/colorPaletteSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const customColorPalettes: Record<string, Palette> = {
obsidian: {
version: 102,

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Keybindings', () => {
test('Should execute command', async ({ comfyPage }) => {
await comfyPage.registerCommand('TestCommand', () => {

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Copy Paste', () => {
test('Can copy and paste node', async ({ comfyPage }) => {
await comfyPage.clickEmptyLatentNode()

View File

@@ -1,8 +1,13 @@
import { Locator, expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Load workflow warning', () => {
test('Should display a warning when loading a workflow with missing nodes', async ({
comfyPage
@@ -36,6 +41,10 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('missing/missing_nodes')
await comfyPage.closeDialog()
// Wait for any async operations to complete after dialog closes
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(100)
// Make a change to the graph
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('DOM Widget', () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/collapsed_multiline')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Execution', () => {
test('Report error on unconnected slot', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { SettingParams } from '../../src/types/settingTypes'
import type { SettingParams } from '../../src/platform/settings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Topbar commands', () => {
@@ -247,7 +247,7 @@ test.describe('Topbar commands', () => {
test.describe('Dialog', () => {
test('Should allow showing a prompt dialog', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].extensionManager.dialog
void window['app'].extensionManager.dialog
.prompt({
title: 'Test Prompt',
message: 'Test Prompt Message'
@@ -267,7 +267,7 @@ test.describe('Topbar commands', () => {
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
window['app'].extensionManager.dialog
void window['app'].extensionManager.dialog
.confirm({
title: 'Test Confirm',
message: 'Test Confirm Message'
@@ -284,7 +284,7 @@ test.describe('Topbar commands', () => {
test('Should allow dismissing a dialog', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['value'] = 'foo'
window['app'].extensionManager.dialog
void window['app'].extensionManager.dialog
.confirm({
title: 'Test Confirm',
message: 'Test Confirm Message'

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Feature Flags', () => {
test('Client and server exchange feature flags on connection', async ({
comfyPage

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Graph', () => {
// Should be able to fix link input slot index after swap the input order
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Graph Canvas Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
// Set link render mode to spline to make sure it's not affected by other tests'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1,8 +1,13 @@
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Group Node', () => {
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'

View File

@@ -0,0 +1,131 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('History API v2', () => {
const TEST_PROMPT_ID = 'test-prompt-id'
const TEST_CLIENT_ID = 'test-client'
test('Can fetch history with new v2 format', async ({ comfyPage }) => {
// Set up mocked history with tasks
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
// Verify history_v2 API response format
const result = await comfyPage.page.evaluate(async () => {
try {
const response = await window['app'].api.getHistory()
return { success: true, data: response }
} catch (error) {
console.error('Failed to fetch history:', error)
return { success: false, error: error.message }
}
})
expect(result.success).toBe(true)
expect(result.data).toHaveProperty('History')
expect(Array.isArray(result.data.History)).toBe(true)
expect(result.data.History.length).toBeGreaterThan(0)
const historyItem = result.data.History[0]
// Verify the new prompt structure (object instead of array)
expect(historyItem.prompt).toHaveProperty('priority')
expect(historyItem.prompt).toHaveProperty('prompt_id')
expect(historyItem.prompt).toHaveProperty('extra_data')
expect(typeof historyItem.prompt.priority).toBe('number')
expect(typeof historyItem.prompt.prompt_id).toBe('string')
expect(historyItem.prompt.extra_data).toHaveProperty('client_id')
})
test('Can load workflow from history using history_v2 endpoint', async ({
comfyPage
}) => {
// Simple mock workflow for testing
const mockWorkflow = {
version: 0.4,
nodes: [{ id: 1, type: 'TestNode', pos: [100, 100], size: [200, 100] }],
links: [],
groups: [],
config: {},
extra: {}
}
// Set up history with workflow data
await comfyPage
.setupHistory()
.withTask(['example.webp'], 'images', {
prompt: {
priority: 0,
prompt_id: TEST_PROMPT_ID,
extra_data: {
client_id: TEST_CLIENT_ID,
extra_pnginfo: { workflow: mockWorkflow }
}
}
})
.setupRoutes()
// Load initial workflow to clear canvas
await comfyPage.loadWorkflow('simple_slider')
await comfyPage.nextFrame()
// Load workflow from history
const loadResult = await comfyPage.page.evaluate(async (promptId) => {
try {
const workflow =
await window['app'].api.getWorkflowFromHistory(promptId)
if (workflow) {
await window['app'].loadGraphData(workflow)
return { success: true }
}
return { success: false, error: 'No workflow found' }
} catch (error) {
console.error('Failed to load workflow from history:', error)
return { success: false, error: error.message }
}
}, TEST_PROMPT_ID)
expect(loadResult.success).toBe(true)
// Verify workflow loaded correctly
await comfyPage.nextFrame()
const nodeInfo = await comfyPage.page.evaluate(() => {
try {
const graph = window['app'].graph
return {
success: true,
nodeCount: graph.nodes?.length || 0,
firstNodeType: graph.nodes?.[0]?.type || null
}
} catch (error) {
return { success: false, error: error.message }
}
})
expect(nodeInfo.success).toBe(true)
expect(nodeInfo.nodeCount).toBe(1)
expect(nodeInfo.firstNodeType).toBe('TestNode')
})
test('Handles missing workflow data gracefully', async ({ comfyPage }) => {
// Set up empty history routes
await comfyPage.setupHistory().setupRoutes()
// Test loading from history with invalid prompt_id
const result = await comfyPage.page.evaluate(async () => {
try {
const workflow =
await window['app'].api.getWorkflowFromHistory('invalid-id')
return { success: true, workflow }
} catch (error) {
console.error('Expected error for invalid prompt_id:', error)
return { success: false, error: error.message }
}
})
// Should handle gracefully without throwing
expect(result.success).toBe(true)
expect(result.workflow).toBeNull()
})
})

View File

@@ -1,12 +1,17 @@
import { Locator, expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -1012,6 +1017,8 @@ test.describe('Canvas Navigation', () => {
test('Shift + mouse wheel should pan canvas horizontally', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Canvas.MouseWheelScroll', 'panning')
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Keybindings', () => {
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
comfyPage

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
function listenForEvent(): Promise<Event> {
return new Promise<Event>((resolve) => {
document.addEventListener('litegraph:canvas', (e) => resolve(e), {

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Load Workflow in Media', () => {
const fileNames = [
'workflow.webp',

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('LOD Threshold', () => {
test('Should switch to low quality mode at correct zoom threshold', async ({
comfyPage

View File

@@ -178,6 +178,72 @@ test.describe('Menu', () => {
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
test('Can navigate Theme menu and switch between Dark and Light themes', async ({
comfyPage
}) => {
const { topbar } = comfyPage.menu
// Take initial screenshot with default theme
await comfyPage.attachScreenshot('theme-initial')
// Open the topbar menu
const menu = await topbar.openTopbarMenu()
await expect(menu).toBeVisible()
// Get theme menu items
const {
submenu: themeSubmenu,
darkTheme: darkThemeItem,
lightTheme: lightThemeItem
} = await topbar.getThemeMenuItems()
await expect(darkThemeItem).toBeVisible()
await expect(lightThemeItem).toBeVisible()
// Switch to Light theme
await topbar.switchTheme('light')
// Verify menu stays open and Light theme shows as active
await expect(menu).toBeVisible()
await expect(themeSubmenu).toBeVisible()
// Check that Light theme is active
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
// Screenshot with light theme active
await comfyPage.attachScreenshot('theme-menu-light-active')
// Verify ColorPalette setting is set to "light"
expect(await comfyPage.getSetting('Comfy.ColorPalette')).toBe('light')
// Close menu to see theme change
await topbar.closeTopbarMenu()
// Re-open menu and get theme items again
await topbar.openTopbarMenu()
const themeItems2 = await topbar.getThemeMenuItems()
// Switch back to Dark theme
await topbar.switchTheme('dark')
// Verify menu stays open and Dark theme shows as active
await expect(menu).toBeVisible()
await expect(themeItems2.submenu).toBeVisible()
// Check that Dark theme is active and Light theme is not
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(false)
// Screenshot with dark theme active
await comfyPage.attachScreenshot('theme-menu-dark-active')
// Verify ColorPalette setting is set to "dark"
expect(await comfyPage.getSetting('Comfy.ColorPalette')).toBe('dark')
// Close menu
await topbar.closeTopbarMenu()
})
})
// Only test 'Top' to reduce test time.

View File

@@ -4,6 +4,10 @@ import type { ComfyApp } from '../../src/scripts/app'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node Badge', () => {
test('Can add badge', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// If an input is optional by node definition, it should be shown as
// a hollow circle no matter what shape it was defined in the workflow JSON.
test.describe('Optional input', () => {

View File

@@ -46,7 +46,7 @@ test.describe('Node Help', () => {
// Click the help button in the selection toolbox
const helpButton = comfyPage.selectionToolbox.locator(
'button:has(.pi-question-circle)'
'button[data-testid="info-button"]'
)
await expect(helpButton).toBeVisible()
await helpButton.click()
@@ -164,7 +164,7 @@ test.describe('Node Help', () => {
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -194,7 +194,7 @@ test.describe('Node Help', () => {
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -228,7 +228,7 @@ test.describe('Node Help', () => {
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -276,7 +276,7 @@ test.describe('Node Help', () => {
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -348,7 +348,7 @@ This is documentation for a custom node.
}
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
if (await helpButton.isVisible()) {
await helpButton.click()
@@ -389,7 +389,7 @@ This is documentation for a custom node.
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -456,7 +456,7 @@ This is English documentation.
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -479,7 +479,7 @@ This is English documentation.
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -522,7 +522,7 @@ This is English documentation.
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
@@ -538,7 +538,7 @@ This is English documentation.
// Click help button again
const helpButton2 = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton2.click()

View File

@@ -3,6 +3,10 @@ import {
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node search box', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'search box')

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Note Node', () => {
test('Can load node nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/note_nodes')

View File

@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Primitive Node', () => {
test('Can load with correct size', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive/primitive_node')

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Remote COMBO Widget', () => {
const mockOptions = ['d', 'c', 'b', 'a']
@@ -190,7 +191,9 @@ test.describe('Remote COMBO Widget', () => {
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator('.selection-toolbox .pi-refresh')
comfyPage.page.locator(
'.selection-toolbox button[data-testid="refresh-button"]'
)
).toBeVisible()
})

View File

@@ -40,6 +40,7 @@ test.describe('Reroute Node', () => {
test.describe('LiteGraph Native Reroute Node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -3,6 +3,10 @@ import { expect } from '@playwright/test'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Canvas Right Click Menu', () => {
test('Can add node', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -4,6 +4,9 @@ import { comfyPageFixture } from '../fixtures/ComfyPage'
const test = comfyPageFixture
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'
@@ -149,7 +152,7 @@ test.describe('Selection Toolbox', () => {
// Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes
const selectedNode = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
expect(selectedNode.getProperty('color')).not.toBeNull()
expect(await selectedNode.getProperty('color')).not.toBeNull()
})
test('color picker shows current color of selected nodes', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,181 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Selection Toolbox - More Options Submenus', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await comfyPage.selectNodes(['KSampler'])
await comfyPage.nextFrame()
})
const openMoreOptions = async (comfyPage: any) => {
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(500)
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
timeout: 5000
})
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await comfyPage.page.click('[data-testid="more-options-button"]')
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click({ force: true })
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(2000)
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
})
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
const initialShape = await nodeRef.getProperty<number>('shape')
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Shape', { exact: true }).click()
await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({
timeout: 5000
})
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
const newShape = await nodeRef.getProperty<number>('shape')
expect(newShape).not.toBe(initialShape)
expect(newShape).toBe(1)
})
test('changes node color via Color submenu swatch', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
const initialColor = await nodeRef.getProperty<string | undefined>('color')
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Color', { exact: true }).click()
const blueSwatch = comfyPage.page.locator('[title="Blue"]')
await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 })
await blueSwatch.first().click()
await comfyPage.nextFrame()
const newColor = await nodeRef.getProperty<string | undefined>('color')
expect(newColor).toBe('#223')
if (initialColor) {
expect(newColor).not.toBe(initialColor)
}
})
test('renames a node using Rename action', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
await openMoreOptions(comfyPage)
await comfyPage.page
.getByText('Rename', { exact: true })
.click({ force: true })
const input = comfyPage.page.locator(
'.group-title-editor.node-title-editor .editable-text input'
)
await expect(input).toBeVisible()
await input.fill('RenamedNode')
await input.press('Enter')
await comfyPage.nextFrame()
const newTitle = await nodeRef.getProperty<string>('title')
expect(newTitle).toBe('RenamedNode')
})
test('closes More Options menu when clicking outside', async ({
comfyPage
}) => {
await openMoreOptions(comfyPage)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible({ timeout: 5000 })
await comfyPage.page
.locator('#graph-canvas')
.click({ position: { x: 0, y: 50 }, force: true })
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).not.toBeVisible()
})
test('closes More Options menu when clicking the button again (toggle)', async ({
comfyPage
}) => {
await openMoreOptions(comfyPage)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible({ timeout: 5000 })
await comfyPage.page.evaluate(() => {
const btn = document.querySelector('[data-testid="more-options-button"]')
if (btn) {
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
detail: 1
})
btn.dispatchEvent(event)
}
})
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(500)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).not.toBeVisible()
})
})

View File

@@ -160,7 +160,9 @@ test.describe.skip('Queue sidebar', () => {
comfyPage
}) => {
await comfyPage.nextFrame()
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test('maintains active gallery item when new tasks are added', async ({
@@ -174,7 +176,9 @@ test.describe.skip('Queue sidebar', () => {
const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage)
await newTask.waitFor({ state: 'visible' })
// The active gallery item should still be the initial image
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test.describe('Gallery navigation', () => {
@@ -196,7 +200,9 @@ test.describe.skip('Queue sidebar', () => {
delay: 256
})
await comfyPage.nextFrame()
expect(comfyPage.menu.queueTab.getGalleryImage(end)).toBeVisible()
await expect(
comfyPage.menu.queueTab.getGalleryImage(end)
).toBeVisible()
})
})
})

View File

@@ -187,6 +187,7 @@ test.describe('Workflows sidebar', () => {
test('Can save workflow as with same name', async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
await comfyPage.nextFrame()
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5.json'
])

View File

@@ -1,4 +1,5 @@
import { Page, expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
@@ -79,6 +80,12 @@ test.describe('Templates', () => {
// Load a template
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.page
.locator(
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
)
.click()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()
@@ -101,48 +108,72 @@ test.describe('Templates', () => {
expect(await comfyPage.templates.content.isVisible()).toBe(true)
})
test('Uses title field as fallback when the key is not found in locales', async ({
test('Uses proper locale files for templates', async ({ comfyPage }) => {
// Set locale to French before opening templates
await comfyPage.setSetting('Comfy.Locale', 'fr')
// Load the templates dialog and wait for the French index file request
const requestPromise = comfyPage.page.waitForRequest(
'**/templates/index.fr.json'
)
await comfyPage.executeCommand('Comfy.BrowseTemplates')
const request = await requestPromise
// Verify French index was requested
expect(request.url()).toContain('templates/index.fr.json')
await expect(comfyPage.templates.content).toBeVisible()
})
test('Falls back to English templates when locale file not found', async ({
comfyPage
}) => {
// Capture request for the index.json
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
// Add a new template that won't have a translation pre-generated
const response = [
{
moduleName: 'default',
title: 'FALLBACK CATEGORY',
type: 'image',
templates: [
{
name: 'unknown_key_has_no_translation_available',
title: 'FALLBACK TEMPLATE NAME',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'No translations found'
}
]
}
]
// Set locale to a language that doesn't have a template file
await comfyPage.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Expect the title to be used as fallback for template cards
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const englishRequest = await englishRequestPromise
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(englishRequest.url()).toContain('templates/index.json')
// Verify English titles are shown as fallback
await expect(
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
comfyPage.templates.content.getByRole('heading', {
name: 'Image Generation'
})
).toBeVisible()
// Expect the title to be used as fallback for the template categories
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({
@@ -150,46 +181,33 @@ test.describe('Templates', () => {
}) => {
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.templates.content.waitFor({ state: 'visible' })
// Wait for at least one template card to appear
await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({
timeout: 5000
})
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
const nav = comfyPage.page
.locator('header')
.filter({ hasText: 'Templates' })
// Take snapshot of the template grid
const templateGrid = comfyPage.templates.content.locator('.grid').first()
const cardCount = await comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.count()
expect(cardCount).toBeGreaterThan(0)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
await expect(nav).toBeVisible() // Nav should be visible at desktop size
// Check cards at mobile viewport size
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
const mobileSize = { width: 640, height: 800 }
await comfyPage.page.setViewportSize(mobileSize)
expect(cardCount).toBeGreaterThan(0)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png')
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
// Check cards at tablet size
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
const tabletSize = { width: 1024, height: 800 }
await comfyPage.page.setViewportSize(tabletSize)
expect(cardCount).toBeGreaterThan(0)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png')
})
test('hover effects work on template cards', async ({ comfyPage }) => {
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Get a template card
const firstCard = comfyPage.page.locator('.template-card').first()
await expect(firstCard).toBeVisible({ timeout: 5000 })
// Take snapshot before hover
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
// Hover over the card
await firstCard.hover()
// Take snapshot after hover to verify hover effect
await expect(firstCard).toHaveScreenshot('template-card-after-hover.png')
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})
test('template cards descriptions adjust height dynamically', async ({
@@ -256,21 +274,42 @@ test.describe('Templates', () => {
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Verify cards are visible with varying content lengths
// Wait for cards to load
await expect(
comfyPage.page.getByText('This is a short description.')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByText('This is a medium length description')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByText('This is a much longer description')
comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
).toBeVisible({ timeout: 5000 })
// Take snapshot of a grid with specific cards
const templateGrid = comfyPage.templates.content
.locator('.grid:has-text("Short Description")')
.first()
// Verify all three cards with different descriptions are visible
const shortDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
const mediumDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-medium-description"]'
)
const longDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-long-description"]'
)
await expect(shortDescCard).toBeVisible()
await expect(mediumDescCard).toBeVisible()
await expect(longDescCard).toBeVisible()
// Verify descriptions are visible and have line-clamp class
// The description is in a p tag with text-muted class
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
await expect(shortDesc).toContainText('short description')
await expect(mediumDesc).toContainText('medium length description')
await expect(longDesc).toContainText('much longer description')
// Verify grid layout maintains consistency
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot(
'template-grid-varying-content.png'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

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