Compare commits

..

23 Commits

Author SHA1 Message Date
GitHub Action
5a71450b85 [automated] Apply ESLint and Prettier fixes 2025-12-01 22:10:56 +00:00
coderabbitai[bot]
6031e6ccf2 📝 Add docstrings to drjkl/byom-2
Docstrings generation was requested by @guill.

* https://github.com/Comfy-Org/ComfyUI_frontend/pull/6969#issuecomment-3583609253

The following files were modified:

* `src/components/dialog/confirm/confirmDialog.ts`
* `src/composables/useFeatureFlags.ts`
* `src/platform/assets/services/assetService.ts`
* `src/services/dialogService.ts`
* `src/stores/dialogStore.ts`
2025-12-01 22:08:29 +00:00
DrJKL
4417b0d907 Update flag name 2025-12-01 13:40:51 -08:00
DrJKL
ff988cd6f1 cleanup: More consistent asset name 2025-12-01 13:39:14 -08:00
DrJKL
38cc93b8f6 Grammar is hard 2025-12-01 13:35:24 -08:00
DrJKL
23d8ccd394 UX: Gate the buttons behind a feature flag 2025-12-01 13:31:42 -08:00
DrJKL
23d01f0b34 UX: Handle failed update 2025-12-01 13:24:24 -08:00
DrJKL
64a7955eca UX: Add progress and confirmation to deletion 2025-12-01 13:09:08 -08:00
DrJKL
a5bd37ef56 Properly resolve MaybeRef 2025-12-01 12:00:00 -08:00
Alexander Brown
ca539a6fd0 Update src/platform/assets/components/AssetBadgeGroup.vue
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-01 11:50:44 -08:00
DrJKL
585e47f2df We should remove that reexport and standardize the import 2025-12-01 11:48:17 -08:00
DrJKL
aa13e4c385 i18n: internationalize deletion confirmation 2025-12-01 11:48:17 -08:00
DrJKL
57ed0afde6 nits: Typo and unused ref 2025-12-01 11:48:17 -08:00
DrJKL
3a18d27b9d WIP: Real API calls 2025-12-01 11:48:16 -08:00
DrJKL
f9e8dfdbe7 style: Hide details button for now 2025-12-01 11:48:16 -08:00
DrJKL
ed68b085cc style: Make sure extra properties hug the bottom of the card. 2025-12-01 11:48:15 -08:00
DrJKL
bb9475fed6 Cleanup: Remove dark-theme variants in buttonTypes. Really need to migrate this to CVA and setup the right types. 2025-12-01 11:48:14 -08:00
DrJKL
ba43494d32 WIP: Deleting assets, UI only, also not wired up 2025-12-01 11:48:14 -08:00
DrJKL
df82698f1e type: Add component prop typesafety to dialogs 2025-12-01 11:48:13 -08:00
DrJKL
d4b993b16d style: Fix card padding 2025-12-01 11:48:13 -08:00
DrJKL
04ab767649 WIP: Renaming Assets, UI, not hooked up to backend. 2025-12-01 11:48:12 -08:00
DrJKL
0b7d0f1d35 style: non-functional buttons and menu 2025-12-01 11:48:11 -08:00
DrJKL
0dfe36f1f8 style: Align card styles with Designs 2025-12-01 11:48:08 -08:00
1490 changed files with 37771 additions and 149972 deletions

View File

@@ -122,7 +122,7 @@ echo " pnpm build - Build for production"
echo " pnpm test:unit - Run unit tests" echo " pnpm test:unit - Run unit tests"
echo " pnpm typecheck - Run TypeScript checks" echo " pnpm typecheck - Run TypeScript checks"
echo " pnpm lint - Run ESLint" echo " pnpm lint - Run ESLint"
echo " pnpm format - Format code with oxfmt" echo " pnpm format - Format code with Prettier"
echo "" echo ""
echo "Next steps:" echo "Next steps:"
echo "1. Run 'pnpm dev' to start developing" echo "1. Run 'pnpm dev' to start developing"

View File

@@ -0,0 +1,21 @@
---
description: Creating unit tests
globs:
alwaysApply: false
---
# Creating unit tests
- This project uses `vitest` for unit testing
- Tests are stored in the `test/` directory
- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux
- e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms
- Tests should be mocked properly
- Mocks should be cleanly written and easy to understand
- Mocks should be re-usable where possible
## Unit test style
- Prefer the use of `test.extend` over loose variables
- To achieve this, import `test as baseTest` from `vitest`
- Never use `it`; `test` should be used in place of this

61
.cursorrules Normal file
View File

@@ -0,0 +1,61 @@
# Vue 3 Composition API Project Rules
## Vue 3 Composition API Best Practices
- Use setup() function for component logic
- Utilize ref and reactive for reactive state
- Implement computed properties with computed()
- Use watch and watchEffect for side effects
- Implement lifecycle hooks with onMounted, onUpdated, etc.
- Utilize provide/inject for dependency injection
- Use vue 3.5 style of default prop declaration. Example:
```typescript
const { nodes, showTotal = true } = defineProps<{
nodes: ApiNodeCost[]
showTotal?: boolean
}>()
```
- Organize vue component in <template> <script> <style> order
## Project Structure
```
src/
components/
constants/
composables/
views/
stores/
services/
App.vue
main.ts
```
## Styling Guidelines
- Use Tailwind CSS for styling
- Implement responsive design with Tailwind CSS
## PrimeVue Component Guidelines
DO NOT use deprecated PrimeVue components. Use these replacements instead:
- Dropdown → Use Select (import from 'primevue/select')
- OverlayPanel → Use Popover (import from 'primevue/popover')
- Calendar → Use DatePicker (import from 'primevue/datepicker')
- InputSwitch → Use ToggleSwitch (import from 'primevue/toggleswitch')
- Sidebar → Use Drawer (import from 'primevue/drawer')
- Chips → Use AutoComplete with multiple enabled and typeahead disabled
- TabMenu → Use Tabs without panels
- Steps → Use Stepper without panels
- InlineMessage → Use Message component
## Development Guidelines
1. Leverage VueUse functions for performance-enhancing styles
2. Use es-toolkit for utility functions
3. Use TypeScript for type safety
4. Implement proper props and emits definitions
5. Utilize Vue 3's Teleport component when needed
6. Use Suspense for async components
7. Implement proper error handling
8. Follow Vue 3 style guide and naming conventions
9. Use Vite for fast development and building
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
11. Never use deprecated PrimeVue components listed above

View File

@@ -14,25 +14,25 @@ DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
# Allow dev server access from remote IP addresses. # Allow dev server access from remote IP addresses.
# If true, the vite dev server will listen on all addresses, including LAN # If true, the vite dev server will listen on all addresses, including LAN
# and public addresses. # and public addresses.
# VITE_REMOTE_DEV=true VITE_REMOTE_DEV=false
# The directory containing the ComfyUI installation used to run Playwright tests. # The directory containing the ComfyUI installation used to run Playwright tests.
# If you aren't using a separate install for testing, point this to your regular install. # If you aren't using a separate install for testing, point this to your regular install.
TEST_COMFYUI_DIR=/home/ComfyUI TEST_COMFYUI_DIR=/home/ComfyUI
# Whether to enable minification of the frontend code. # Whether to enable minification of the frontend code.
# ENABLE_MINIFY=true ENABLE_MINIFY=true
# Whether to disable proxying the `/templates` route. If true, allows you to # Whether to disable proxying the `/templates` route. If true, allows you to
# serve templates from the ComfyUI_frontend/public/templates folder (for # serve templates from the ComfyUI_frontend/public/templates folder (for
# locally testing changes to templates). When false or nonexistent, the # locally testing changes to templates). When false or nonexistent, the
# templates are served via the normal method from the server's python site # templates are served via the normal method from the server's python site
# packages. # packages.
# DISABLE_TEMPLATES_PROXY=true DISABLE_TEMPLATES_PROXY=false
# If playwright tests are being run via vite dev server, Vue plugins will # If playwright tests are being run via vite dev server, Vue plugins will
# invalidate screenshots. When `true`, vite plugins will not be loaded. # invalidate screenshots. When `true`, vite plugins will not be loaded.
# DISABLE_VUE_PLUGINS=true DISABLE_VUE_PLUGINS=false
# Algolia credentials required for developing with the new custom node manager. # Algolia credentials required for developing with the new custom node manager.
ALGOLIA_APP_ID=4E0RO38HS8 ALGOLIA_APP_ID=4E0RO38HS8

14
.github/AGENTS.md vendored
View File

@@ -1,14 +0,0 @@
# PR Review Context
Context for automated PR review system.
## Review Scope
This automated review performs comprehensive analysis:
- Architecture and design patterns
- Security vulnerabilities
- Performance implications
- Code quality and maintainability
- Integration concerns
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.

39
.github/CLAUDE.md vendored
View File

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

View File

@@ -1,23 +0,0 @@
name: Start ComfyUI Server
description: 'Start ComfyUI server in a container environment (assumes ComfyUI is pre-installed)'
inputs:
front_end_root:
description: 'Path to frontend dist directory'
required: false
default: '$GITHUB_WORKSPACE/dist'
timeout:
description: 'Timeout in seconds for server startup'
required: false
default: '600'
runs:
using: 'composite'
steps:
- name: Copy devtools and start server
shell: bash
run: |
set -euo pipefail
cp -r ./tools/devtools/* /ComfyUI/custom_nodes/ComfyUI_devtools/
cd /ComfyUI && python3 main.py --cpu --multi-user --front-end-root "${{ inputs.front_end_root }}" &
wait-for-it --service 127.0.0.1:8188 -t ${{ inputs.timeout }}

View File

@@ -42,7 +42,7 @@ jobs:
- name: Run Stylelint with auto-fix - name: Run Stylelint with auto-fix
run: pnpm stylelint:fix run: pnpm stylelint:fix
- name: Run oxfmt with auto-format - name: Run Prettier with auto-format
run: pnpm format run: pnpm format
- name: Check for changes - name: Check for changes
@@ -60,7 +60,7 @@ jobs:
git config --local user.email "action@github.com" git config --local user.email "action@github.com"
git config --local user.name "GitHub Action" git config --local user.name "GitHub Action"
git add . git add .
git commit -m "[automated] Apply ESLint and Oxfmt fixes" git commit -m "[automated] Apply ESLint and Prettier fixes"
git push git push
- name: Final validation - name: Final validation
@@ -80,7 +80,7 @@ jobs:
issue_number: context.issue.number, issue_number: context.issue.number,
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting' body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting'
}) })
- name: Comment on PR about manual fix needed - name: Comment on PR about manual fix needed

View File

@@ -1,26 +0,0 @@
# Description: Runs shellcheck on tracked shell scripts when they change
name: "CI: Shell Validation"
on:
push:
branches:
- main
paths:
- '**/*.sh'
pull_request:
paths:
- '**/*.sh'
jobs:
shell-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install shellcheck
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
- name: Run shellcheck
run: bash ./scripts/cicd/check-shell.sh

View File

@@ -1,9 +1,9 @@
# Description: Deploys test results from forked PRs (forks can't access deployment secrets) # Description: Deploys test results from forked PRs (forks can't access deployment secrets)
name: 'CI: Tests E2E (Deploy for Forks)' name: "CI: Tests E2E (Deploy for Forks)"
on: on:
workflow_run: workflow_run:
workflows: ['CI: Tests E2E'] workflows: ["CI: Tests E2E"]
types: [requested, completed] types: [requested, completed]
env: env:
@@ -81,7 +81,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
run: | run: |
# Rename merged report if exists # Rename merged report if exists
[ -d "reports/playwright-report-chromium-merged" ] && \ [ -d "reports/playwright-report-chromium-merged" ] && \

View File

@@ -1,5 +1,5 @@
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages # Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
name: 'CI: Tests E2E' name: "CI: Tests E2E"
on: on:
push: push:
@@ -15,56 +15,65 @@ concurrency:
jobs: jobs:
setup: setup:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
# Setup Test Environment, build frontend but do not start server yet
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
- name: Setup frontend - name: Setup frontend
uses: ./.github/actions/setup-frontend uses: ./.github/actions/setup-frontend
with: with:
include_build_step: true include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
# Upload only built dist/ (containerized test jobs will pnpm install without cache) # Save the entire workspace as cache for later test jobs to restore
- name: Upload built frontend - name: Generate cache key
uses: actions/upload-artifact@v4 id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
with: with:
name: frontend-dist path: .
path: dist/ key: comfyui-setup-${{ steps.cache-key.outputs.key }}
retention-days: 1
# Sharded chromium tests # Sharded chromium tests
playwright-tests-chromium-sharded: playwright-tests-chromium-sharded:
needs: setup needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions: permissions:
contents: read contents: read
packages: read
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8] shardTotal: [8]
steps: steps:
- name: Checkout repository # download built frontend repo from setup job
uses: actions/checkout@v5 - name: Wait for cache propagation
- name: Download built frontend run: sleep 10
uses: actions/download-artifact@v4 - name: Restore cached setup
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
with: with:
name: frontend-dist fail-on-cache-miss: true
path: dist/ path: .
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
- name: Start ComfyUI server # Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
uses: ./.github/actions/start-comfyui-server - name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup nodejs, pnpm, reuse built frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Install frontend deps # Run sharded tests and upload sharded reports
run: pnpm install --frozen-lockfile
# Run sharded tests (browsers pre-installed in container)
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) - name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright id: playwright
run: pnpm exec 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
@@ -84,48 +93,43 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
needs: setup needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions: permissions:
contents: read contents: read
packages: read
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
browser: [chromium-2x, chromium-0.5x, mobile-chrome] browser: [chromium-2x, chromium-0.5x, mobile-chrome]
steps: steps:
- name: Checkout repository # download built frontend repo from setup job
uses: actions/checkout@v5 - name: Wait for cache propagation
- name: Download built frontend run: sleep 10
uses: actions/download-artifact@v4 - name: Restore cached setup
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
with: with:
name: frontend-dist fail-on-cache-miss: true
path: dist/ path: .
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
- name: Start ComfyUI server # Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
uses: ./.github/actions/start-comfyui-server - name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup nodejs, pnpm, reuse built frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Install frontend deps # Run tests and upload reports
run: pnpm install --frozen-lockfile
# Run tests (browsers pre-installed in container)
- name: Run Playwright tests (${{ matrix.browser }}) - name: Run Playwright tests (${{ matrix.browser }})
id: playwright id: playwright
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
- name: Generate HTML and JSON reports
if: always()
run: | run: |
# Generate HTML report from blob # Run tests with both HTML and JSON reporters
pnpm exec playwright merge-reports --reporter=html ./blob-report
# Generate JSON report separately with explicit output path
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \ PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright merge-reports --reporter=json ./blob-report pnpm exec playwright test --project=${{ matrix.browser }} \
--reporter=list \
--reporter=html \
--reporter=json
- name: Upload Playwright report - name: Upload Playwright report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -135,7 +139,7 @@ jobs:
path: ./playwright-report/ path: ./playwright-report/
retention-days: 30 retention-days: 30
# Merge sharded test reports (no container needed - only runs CLI) # Merge sharded test reports
merge-reports: merge-reports:
needs: [playwright-tests-chromium-sharded] needs: [playwright-tests-chromium-sharded]
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -144,10 +148,11 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Install pnpm # Setup Test Environment, we only need playwright to merge reports
uses: pnpm/action-setup@v4 - name: Setup frontend
with: uses: ./.github/actions/setup-frontend
version: 10 - name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Download blob reports - name: Download blob reports
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
@@ -159,10 +164,10 @@ jobs:
- name: Merge into HTML Report - name: Merge into HTML Report
run: | run: |
# Generate HTML report # Generate HTML report
pnpm dlx @playwright/test merge-reports --reporter=html ./all-blob-reports pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
# Generate JSON report separately with explicit output path # Generate JSON report separately with explicit output path
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \ PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
- name: Upload HTML report - name: Upload HTML report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -223,7 +228,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \ bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \ "${{ github.event.pull_request.number }}" \

View File

@@ -16,10 +16,6 @@ on:
type: boolean type: boolean
default: false default: false
concurrency:
group: backport-${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
cancel-in-progress: false
jobs: jobs:
backport: backport:
if: > if: >
@@ -361,42 +357,6 @@ jobs:
if: steps.filter-targets.outputs.skip != 'true' && failure() && steps.backport.outputs.failed if: steps.filter-targets.outputs.skip != 'true' && failure() && steps.backport.outputs.failed
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
BACKPORT_AGENT_PROMPT_TEMPLATE: |
Backport PR #${PR_NUMBER} (${PR_URL}) to ${target}.
Cherry-pick merge commit ${MERGE_COMMIT} onto new branch
${BACKPORT_BRANCH} from origin/${target}.
Resolve conflicts in: ${CONFLICTS_INLINE}.
For test snapshots (browser_tests/**/*-snapshots/), accept PR version if
changed in original PR, else keep target. For package.json versions, keep
target branch. For pnpm-lock.yaml, regenerate with pnpm install.
Ask user for non-obvious conflicts.
Create PR titled "[backport ${target}] <original title>" with label "backport".
See .github/workflows/pr-backport.yaml for workflow details.
COMMENT_BODY_TEMPLATE: |
### ⚠️ Backport to `${target}` failed
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
<details>
<summary>📄 Conflicting files</summary>
```
${CONFLICTS_BLOCK}
```
</details>
<details>
<summary>🤖 Prompt for AI Agents</summary>
```
${AGENT_PROMPT}
```
</details>
---
cc @${PR_AUTHOR}
run: | run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit) PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit)
@@ -419,27 +379,10 @@ jobs:
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
elif [ "${reason}" = "conflicts" ]; then elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ') # Convert comma-separated conflicts back to newlines for display
SAFE_TARGET=$(echo "$target" | tr '/' '-') CONFLICTS_LIST=$(echo "${conflicts}" | tr ',' '\n' | sed 's/^/- /')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
# envsubst is provided by gettext-base
if ! command -v envsubst >/dev/null 2>&1; then
sudo apt-get update && sudo apt-get install -y gettext-base
fi
AGENT_PROMPT=$(envsubst '${PR_NUMBER} ${PR_URL} ${target} ${MERGE_COMMIT} ${BACKPORT_BRANCH} ${CONFLICTS_INLINE}' <<<"$BACKPORT_AGENT_PROMPT_TEMPLATE")
# Use fenced code block for conflicts to handle special chars in filenames
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
COMMENT_BODY="@${PR_AUTHOR} Backport to \`${target}\` failed: Merge conflicts detected."$'\n\n'"Please manually cherry-pick commit \`${MERGE_COMMIT}\` to the \`${target}\` branch."$'\n\n'"<details><summary>Conflicting files</summary>"$'\n\n'"${CONFLICTS_LIST}"$'\n\n'"</details>"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}" gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi fi
done done

View File

@@ -25,6 +25,7 @@ jobs:
) && ) &&
startsWith(github.event.comment.body, '/update-playwright') ) startsWith(github.event.comment.body, '/update-playwright') )
outputs: outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
pr-number: ${{ steps.pr-info.outputs.pr-number }} pr-number: ${{ steps.pr-info.outputs.pr-number }}
branch: ${{ steps.pr-info.outputs.branch }} branch: ${{ steps.pr-info.outputs.branch }}
comment-id: ${{ steps.find-update-comment.outputs.comment-id }} comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
@@ -63,90 +64,106 @@ jobs:
uses: ./.github/actions/setup-frontend uses: ./.github/actions/setup-frontend
with: with:
include_build_step: true include_build_step: true
# Save expensive build artifacts (Python env, built frontend, node_modules)
# Upload built dist/ (containerized test jobs will pnpm install without cache) # Source code will be checked out fresh in sharded jobs
- name: Upload built frontend - name: Generate cache key
uses: actions/upload-artifact@v4 id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
with: with:
name: frontend-dist path: |
path: dist/ ComfyUI
retention-days: 1 dist
key: comfyui-setup-${{ steps.cache-key.outputs.key }}
# Sharded snapshot updates # Sharded snapshot updates
update-snapshots-sharded: update-snapshots-sharded:
needs: setup needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
shardIndex: [1, 2, 3, 4] shardIndex: [1, 2, 3, 4]
shardTotal: [4] shardTotal: [4]
steps: steps:
# Checkout source code fresh (not cached)
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
ref: ${{ needs.setup.outputs.branch }} ref: ${{ needs.setup.outputs.branch }}
- name: Download built frontend
uses: actions/download-artifact@v4 # Restore expensive build artifacts from setup job
- name: Restore cached artifacts
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
with: with:
name: frontend-dist fail-on-cache-miss: true
path: dist/ path: |
ComfyUI
dist
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
- name: Start ComfyUI server - name: Setup ComfyUI server (from cache)
uses: ./.github/actions/start-comfyui-server uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Install frontend deps - name: Setup nodejs, pnpm, reuse built frontend
run: pnpm install --frozen-lockfile uses: ./.github/actions/setup-frontend
# Run sharded tests with snapshot updates (browsers pre-installed in container) - name: Setup Playwright
uses: ./.github/actions/setup-playwright
# Run sharded tests with snapshot updates
- name: Update snapshots (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) - name: Update snapshots (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright-tests id: playwright-tests
run: pnpm exec playwright test --update-snapshots --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} run: pnpm exec playwright test --update-snapshots --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
continue-on-error: true continue-on-error: true
# Identify and stage only changed snapshot files
- name: Stage changed snapshot files - name: Stage changed snapshot files
id: changed-snapshots id: changed-snapshots
shell: bash
run: | run: |
set -euo pipefail echo "=========================================="
echo "STAGING CHANGED SNAPSHOTS (Shard ${{ matrix.shardIndex }})"
echo "=========================================="
# Configure git safe.directory for container environment # Get list of changed snapshot files
git config --global --add safe.directory "$(pwd)" changed_files=$(git diff --name-only browser_tests/ 2>/dev/null | grep -E '\-snapshots/' || echo "")
# Get list of changed snapshot files (including untracked/new files)
changed_files=$( (
git diff --name-only browser_tests/ 2>/dev/null || true
git ls-files --others --exclude-standard browser_tests/ 2>/dev/null || true
) | sort -u | grep -E '\-snapshots/' || true )
if [ -z "$changed_files" ]; then if [ -z "$changed_files" ]; then
echo "No snapshot changes in shard ${{ matrix.shardIndex }}" echo "No snapshot changes in this shard"
echo "has-changes=false" >> $GITHUB_OUTPUT echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0 exit 0
fi fi
file_count=$(echo "$changed_files" | wc -l) echo "✓ Found changed files:"
echo "Shard ${{ matrix.shardIndex }}: $file_count changed snapshot(s):"
echo "$changed_files" echo "$changed_files"
file_count=$(echo "$changed_files" | wc -l)
echo "Count: $file_count"
echo "has-changes=true" >> $GITHUB_OUTPUT echo "has-changes=true" >> $GITHUB_OUTPUT
echo ""
# Copy changed files to staging directory # Create staging directory
mkdir -p /tmp/changed_snapshots_shard mkdir -p /tmp/changed_snapshots_shard
# Copy only changed files, preserving directory structure
# Strip 'browser_tests/' prefix to avoid double nesting
echo "Copying changed files to staging directory..."
while IFS= read -r file; do while IFS= read -r file; do
[ -f "$file" ] || continue # Remove 'browser_tests/' prefix
file_without_prefix="${file#browser_tests/}" file_without_prefix="${file#browser_tests/}"
# Create parent directories
mkdir -p "/tmp/changed_snapshots_shard/$(dirname "$file_without_prefix")" mkdir -p "/tmp/changed_snapshots_shard/$(dirname "$file_without_prefix")"
# Copy file
cp "$file" "/tmp/changed_snapshots_shard/$file_without_prefix" cp "$file" "/tmp/changed_snapshots_shard/$file_without_prefix"
echo " → $file_without_prefix"
done <<< "$changed_files" done <<< "$changed_files"
echo ""
echo "Staged files for upload:"
find /tmp/changed_snapshots_shard -type f
# Upload ONLY the changed files from this shard # Upload ONLY the changed files from this shard
- name: Upload changed snapshots - name: Upload changed snapshots
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -187,15 +204,9 @@ jobs:
echo "==========================================" echo "=========================================="
echo "DOWNLOADED SNAPSHOT FILES" echo "DOWNLOADED SNAPSHOT FILES"
echo "==========================================" echo "=========================================="
if [ -d "./downloaded-snapshots" ]; then find ./downloaded-snapshots -type f
find ./downloaded-snapshots -type f echo ""
echo "" echo "Total files: $(find ./downloaded-snapshots -type f | wc -l)"
echo "Total files: $(find ./downloaded-snapshots -type f | wc -l)"
else
echo "No snapshot artifacts downloaded (no changes in any shard)"
echo ""
echo "Total files: 0"
fi
# Merge only the changed files into browser_tests # Merge only the changed files into browser_tests
- name: Merge changed snapshots - name: Merge changed snapshots
@@ -206,16 +217,6 @@ jobs:
echo "MERGING CHANGED SNAPSHOTS" echo "MERGING CHANGED SNAPSHOTS"
echo "==========================================" echo "=========================================="
# Check if any artifacts were downloaded
if [ ! -d "./downloaded-snapshots" ]; then
echo "No snapshot artifacts to merge"
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Shards merged: 0"
exit 0
fi
# Verify target directory exists # Verify target directory exists
if [ ! -d "browser_tests" ]; then if [ ! -d "browser_tests" ]; then
echo "::error::Target directory 'browser_tests' does not exist" echo "::error::Target directory 'browser_tests' does not exist"
@@ -260,19 +261,11 @@ jobs:
echo "CHANGES SUMMARY" echo "CHANGES SUMMARY"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "Changed files in browser_tests (including untracked):" echo "Changed files in browser_tests:"
CHANGES=$(git status --porcelain=v1 --untracked-files=all -- browser_tests/) git diff --name-only browser_tests/ | head -20 || echo "No changes"
if [ -z "$CHANGES" ]; then echo ""
echo "No changes" echo "Total changes:"
echo "" git diff --name-only browser_tests/ | wc -l || echo "0"
echo "Total changes:"
echo "0"
else
echo "$CHANGES" | head -50
echo ""
echo "Total changes:"
echo "$CHANGES" | wc -l
fi
- name: Commit updated expectations - name: Commit updated expectations
id: commit id: commit
@@ -280,7 +273,7 @@ jobs:
git config --global user.name 'github-actions' git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com' git config --global user.email 'github-actions@github.com'
if [ -z "$(git status --porcelain=v1 --untracked-files=all -- browser_tests/)" ]; then if git diff --quiet browser_tests/; then
echo "No changes to commit" echo "No changes to commit"
echo "has-changes=false" >> $GITHUB_OUTPUT echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0 exit 0

View File

@@ -20,13 +20,6 @@ on:
required: true required: true
default: 'main' default: 'main'
type: string type: string
schedule:
# 00:00 UTC ≈ 4:00 PM PST / 5:00 PM PDT on the previous calendar day
- cron: '0 0 * * *'
concurrency:
group: release-version-bump
cancel-in-progress: true
jobs: jobs:
bump-version: bump-version:
@@ -36,99 +29,15 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Prepare inputs
id: prepared-inputs
shell: bash
env:
RAW_VERSION_TYPE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version_type || '' }}
RAW_PRE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.pre_release || '' }}
RAW_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || '' }}
run: |
set -euo pipefail
VERSION_TYPE="$RAW_VERSION_TYPE"
PRE_RELEASE="$RAW_PRE_RELEASE"
TARGET_BRANCH="$RAW_BRANCH"
if [[ -z "$VERSION_TYPE" ]]; then
VERSION_TYPE='patch'
fi
if [[ -z "$TARGET_BRANCH" ]]; then
TARGET_BRANCH='main'
fi
{
echo "version_type=$VERSION_TYPE"
echo "pre_release=$PRE_RELEASE"
echo "branch=$TARGET_BRANCH"
} >> "$GITHUB_OUTPUT"
- name: Close stale nightly version bump PRs
if: github.event_name == 'schedule'
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
script: |
const prefix = 'version-bump-'
const closed = []
const prs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100
})
for (const pr of prs) {
if (!pr.head?.ref?.startsWith(prefix)) {
continue
}
if (pr.user?.login !== 'github-actions[bot]') {
continue
}
// Only clean up stale nightly PRs targeting main.
// Adjust here if other target branches should be cleaned.
if (pr.base?.ref !== 'main') {
continue
}
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
})
try {
await github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `heads/${pr.head.ref}`
})
} catch (error) {
if (![404, 422].includes(error.status)) {
throw error
}
}
closed.push(pr.number)
}
core.info(`Closed ${closed.length} stale PR(s).`)
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
ref: ${{ steps.prepared-inputs.outputs.branch }} ref: ${{ github.event.inputs.branch }}
fetch-depth: 0 fetch-depth: 0
persist-credentials: false
- name: Validate branch exists - name: Validate branch exists
env:
TARGET_BRANCH: ${{ steps.prepared-inputs.outputs.branch }}
run: | run: |
BRANCH="$TARGET_BRANCH" BRANCH="${{ github.event.inputs.branch }}"
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
echo "❌ Branch '$BRANCH' does not exist" echo "❌ Branch '$BRANCH' does not exist"
echo "" echo ""
@@ -142,7 +51,7 @@ jobs:
echo "✅ Branch '$BRANCH' exists" echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 uses: pnpm/action-setup@v4
with: with:
version: 10 version: 10
@@ -153,31 +62,16 @@ jobs:
- name: Bump version - name: Bump version
id: bump-version id: bump-version
env:
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
PRE_RELEASE: ${{ steps.prepared-inputs.outputs.pre_release }}
run: | run: |
set -euo pipefail pnpm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
if [[ -n "$PRE_RELEASE" && ! "$VERSION_TYPE" =~ ^pre(major|minor|patch)$ && "$VERSION_TYPE" != "prerelease" ]]; then
echo "❌ pre_release was provided but version_type='$VERSION_TYPE' does not support --preid"
exit 1
fi
if [[ -n "$PRE_RELEASE" ]]; then
pnpm version "$VERSION_TYPE" --preid "$PRE_RELEASE" --no-git-tag-version
else
pnpm version "$VERSION_TYPE" --no-git-tag-version
fi
NEW_VERSION=$(node -p "require('./package.json').version") NEW_VERSION=$(node -p "require('./package.json').version")
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Format PR string - name: Format PR string
id: capitalised id: capitalised
env:
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
run: | run: |
CAPITALISED_TYPE="$VERSION_TYPE" CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT" echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
@@ -188,8 +82,8 @@ jobs:
body: | body: |
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }} ${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}` **Base branch:** `${{ github.event.inputs.branch }}`
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }} branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
base: ${{ steps.prepared-inputs.outputs.branch }} base: ${{ github.event.inputs.branch }}
labels: | labels: |
Release Release

View File

@@ -1,12 +1,12 @@
# Automated bi-weekly workflow to bump ComfyUI frontend RC releases # Automated weekly workflow to bump ComfyUI frontend RC releases
name: "Release: Bi-weekly ComfyUI" name: "Release: Weekly ComfyUI"
on: on:
# Schedule for Monday at 12:00 PM PST (20:00 UTC) # Schedule for Monday at 12:00 PM PST (20:00 UTC)
schedule: schedule:
- cron: '0 20 * * 1' - cron: '0 20 * * 1'
# Allow manual triggering (bypasses bi-weekly check) # Allow manual triggering
workflow_dispatch: workflow_dispatch:
inputs: inputs:
comfyui_fork: comfyui_fork:
@@ -16,39 +16,7 @@ on:
type: string type: string
jobs: jobs:
check-release-week:
runs-on: ubuntu-latest
outputs:
is_release_week: ${{ steps.check.outputs.is_release_week }}
steps:
- name: Check if release week
id: check
run: |
# Anchor date: first bi-weekly release Monday
ANCHOR="2025-12-22"
ANCHOR_EPOCH=$(date -d "$ANCHOR" +%s)
NOW_EPOCH=$(date +%s)
WEEKS_SINCE=$(( (NOW_EPOCH - ANCHOR_EPOCH) / 604800 ))
if [ $((WEEKS_SINCE % 2)) -eq 0 ]; then
echo "Release week (week $WEEKS_SINCE since anchor $ANCHOR)"
echo "is_release_week=true" >> $GITHUB_OUTPUT
else
echo "Not a release week (week $WEEKS_SINCE since anchor $ANCHOR)"
echo "is_release_week=false" >> $GITHUB_OUTPUT
fi
- name: Summary
run: |
echo "## Bi-weekly Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
resolve-version: resolve-version:
needs: check-release-week
if: needs.check-release-week.outputs.is_release_week == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
current_version: ${{ steps.resolve.outputs.current_version }} current_version: ${{ steps.resolve.outputs.current_version }}
@@ -69,7 +37,7 @@ jobs:
- name: Checkout ComfyUI (sparse) - name: Checkout ComfyUI (sparse)
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
repository: Comfy-Org/ComfyUI repository: comfyanonymous/ComfyUI
sparse-checkout: | sparse-checkout: |
requirements.txt requirements.txt
path: comfyui path: comfyui
@@ -95,7 +63,7 @@ jobs:
set -euo pipefail set -euo pipefail
# Run the resolver script # Run the resolver script
if ! RESULT=$(pnpm exec tsx scripts/cicd/resolve-comfyui-release.ts ../comfyui .); then if ! RESULT=$(tsx scripts/cicd/resolve-comfyui-release.ts ../comfyui .); then
echo "Failed to resolve release information" echo "Failed to resolve release information"
exit 1 exit 1
fi fi
@@ -163,8 +131,8 @@ jobs:
echo "- [View workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)" >> $GITHUB_STEP_SUMMARY echo "- [View workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)" >> $GITHUB_STEP_SUMMARY
create-comfyui-pr: create-comfyui-pr:
needs: [check-release-week, resolve-version, trigger-release-if-needed] needs: [resolve-version, trigger-release-if-needed]
if: always() && needs.resolve-version.result == 'success' && (needs.check-release-week.outputs.is_release_week == 'true' || github.event_name == 'workflow_dispatch') if: always() && needs.resolve-version.result == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -184,7 +152,7 @@ jobs:
# Note: This only affects the local checkout, NOT the fork's master branch # Note: This only affects the local checkout, NOT the fork's master branch
# We only push the automation branch, leaving the fork's master untouched # We only push the automation branch, leaving the fork's master untouched
echo "Fetching upstream master..." echo "Fetching upstream master..."
if ! git fetch https://github.com/Comfy-Org/ComfyUI.git master; then if ! git fetch https://github.com/comfyanonymous/ComfyUI.git master; then
echo "Failed to fetch upstream master" echo "Failed to fetch upstream master"
exit 1 exit 1
fi fi
@@ -257,13 +225,13 @@ jobs:
# Extract fork owner from repository name # Extract fork owner from repository name
FORK_OWNER=$(echo "$COMFYUI_FORK" | cut -d'/' -f1) FORK_OWNER=$(echo "$COMFYUI_FORK" | cut -d'/' -f1)
echo "Creating PR from ${COMFYUI_FORK} to Comfy-Org/ComfyUI" echo "Creating PR from ${COMFYUI_FORK} to comfyanonymous/ComfyUI"
# Configure git # Configure git
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
# Create/update branch (reuse same branch name each release cycle) # Create/update branch (reuse same branch name each week)
BRANCH="automation/comfyui-frontend-bump" BRANCH="automation/comfyui-frontend-bump"
git checkout -B "$BRANCH" git checkout -B "$BRANCH"
git add requirements.txt git add requirements.txt
@@ -275,7 +243,7 @@ jobs:
exit 0 exit 0
fi fi
# Force push to fork (overwrites previous release cycle's branch) # Force push to fork (overwrites previous week's branch)
# Note: This intentionally destroys branch history to maintain a single PR # Note: This intentionally destroys branch history to maintain a single PR
# Any review comments or manual commits will need to be re-applied # Any review comments or manual commits will need to be re-applied
if ! git push -f origin "$BRANCH"; then if ! git push -f origin "$BRANCH"; then
@@ -288,7 +256,7 @@ jobs:
# Try to create PR, ignore error if it already exists # Try to create PR, ignore error if it already exists
if ! gh pr create \ if ! gh pr create \
--repo Comfy-Org/ComfyUI \ --repo comfyanonymous/ComfyUI \
--head "${FORK_OWNER}:${BRANCH}" \ --head "${FORK_OWNER}:${BRANCH}" \
--base master \ --base master \
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \ --title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
@@ -297,7 +265,7 @@ jobs:
# Check if PR already exists # Check if PR already exists
set +e set +e
EXISTING_PR=$(gh pr list --repo Comfy-Org/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1) EXISTING_PR=$(gh pr list --repo comfyanonymous/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
PR_LIST_EXIT=$? PR_LIST_EXIT=$?
set -e set -e
@@ -318,7 +286,7 @@ jobs:
run: | run: |
echo "## ComfyUI PR Created" >> $GITHUB_STEP_SUMMARY echo "## ComfyUI PR Created" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "Draft PR created in Comfy-Org/ComfyUI" >> $GITHUB_STEP_SUMMARY echo "Draft PR created in comfyanonymous/ComfyUI" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
cat pr-body.txt >> $GITHUB_STEP_SUMMARY cat pr-body.txt >> $GITHUB_STEP_SUMMARY

View File

@@ -1,29 +1,16 @@
// This file is intentionally kept in CommonJS format (.cjs) // This file is intentionally kept in CommonJS format (.cjs)
// to resolve compatibility issues with dependencies that require CommonJS. // to resolve compatibility issues with dependencies that require CommonJS.
// Do not convert this file to ESModule format unless all dependencies support it. // Do not convert this file to ESModule format unless all dependencies support it.
const { defineConfig } = require('@lobehub/i18n-cli') const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({ module.exports = defineConfig({
modelName: 'gpt-4.1', modelName: 'gpt-4.1',
splitToken: 1024, splitToken: 1024,
saveImmediately: true,
entry: 'src/locales/en', entry: 'src/locales/en',
entryLocale: 'en', entryLocale: 'en',
output: 'src/locales', output: 'src/locales',
outputLocales: [ outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
'zh', reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'zh-TW',
'ru',
'ja',
'ko',
'fr',
'es',
'ar',
'tr',
'pt-BR',
'fa'
],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
'latent' is the short form of 'latent space'. 'latent' is the short form of 'latent space'.
'mask' is in the context of image processing. 'mask' is in the context of image processing.
@@ -31,11 +18,5 @@ module.exports = defineConfig({
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小). - For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology. - For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
- NEVER mix Simplified and Traditional Chinese characters within the same locale. - NEVER mix Simplified and Traditional Chinese characters within the same locale.
IMPORTANT Persian Translation Guidelines:
- For 'fa' locale: Use formal Persian (فارسی رسمی) for professional tone throughout the UI.
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
- Maintain consistency with terminology used in Persian software and design applications.
` `
}) });

1
.npmrc
View File

@@ -1,3 +1,2 @@
ignore-workspace-root-check=true ignore-workspace-root-check=true
catalog-mode=prefer catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

View File

@@ -1,20 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none",
"printWidth": 80,
"ignorePatterns": [
"packages/registry-types/src/comfyRegistryTypes.ts",
"src/types/generatedManagerTypes.ts",
"**/*.md",
"**/*.json",
"**/*.css",
"**/*.yaml",
"**/*.yml",
"**/*.html",
"**/*.svg",
"**/*.xml"
]
}

View File

@@ -2,98 +2,25 @@
"$schema": "./node_modules/oxlint/configuration_schema.json", "$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [ "ignorePatterns": [
".i18nrc.cjs", ".i18nrc.cjs",
".nx/*", "components.d.ts",
"lint-staged.config.js",
"vitest.setup.ts",
"**/vite.config.*.timestamp*", "**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*", "**/vitest.config.*.timestamp*",
"components.d.ts",
"coverage/*",
"dist/*",
"packages/registry-types/src/comfyRegistryTypes.ts", "packages/registry-types/src/comfyRegistryTypes.ts",
"playwright-report/*",
"src/extensions/core/*", "src/extensions/core/*",
"src/scripts/*", "src/scripts/*",
"src/types/generatedManagerTypes.ts", "src/types/generatedManagerTypes.ts",
"src/types/vue-shim.d.ts", "src/types/vue-shim.d.ts"
"test-results/*",
"vitest.setup.ts"
],
"plugins": [
"eslint",
"import",
"oxc",
"typescript",
"unicorn",
"vitest",
"vue"
], ],
"rules": { "rules": {
"no-async-promise-executor": "off", "no-async-promise-executor": "off",
"no-console": [
"error",
{
"allow": [
"warn",
"error"
]
}
],
"no-control-regex": "off", "no-control-regex": "off",
"no-eval": "off", "no-eval": "off",
"no-redeclare": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "primevue/calendar",
"message": "Calendar is deprecated in PrimeVue 4+. Use DatePicker instead: import DatePicker from 'primevue/datepicker'"
},
{
"name": "primevue/dropdown",
"message": "Dropdown is deprecated in PrimeVue 4+. Use Select instead: import Select from 'primevue/select'"
},
{
"name": "primevue/inputswitch",
"message": "InputSwitch is deprecated in PrimeVue 4+. Use ToggleSwitch instead: import ToggleSwitch from 'primevue/toggleswitch'"
},
{
"name": "primevue/overlaypanel",
"message": "OverlayPanel is deprecated in PrimeVue 4+. Use Popover instead: import Popover from 'primevue/popover'"
},
{
"name": "primevue/sidebar",
"message": "Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from 'primevue/drawer'"
},
{
"name": "@/i18n--to-enable",
"importNames": [
"st",
"t",
"te",
"d"
],
"message": "Don't import `@/i18n` directly, prefer `useI18n()`"
}
]
}
],
"no-self-assign": "allow", "no-self-assign": "allow",
"no-unused-expressions": "off", "no-unused-expressions": "off",
"no-unused-private-class-members": "off", "no-unused-private-class-members": "off",
"no-useless-rename": "off", "no-useless-rename": "off",
"import/default": "error",
"import/export": "error",
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": [
"error",
"prefer-top-level"
],
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"typescript/no-this-alias": "off", "typescript/no-this-alias": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off", "typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off", "typescript/no-unsafe-declaration-merging": "off",
@@ -112,18 +39,6 @@
"typescript/restrict-template-expressions": "off", "typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off", "typescript/unbound-method": "off",
"typescript/no-floating-promises": "error", "typescript/no-floating-promises": "error",
"vue/no-import-compiler-macros": "error", "vue/no-import-compiler-macros": "error"
"vue/no-dupe-keys": "error" }
},
"overrides": [
{
"files": [
"**/*.{stories,test,spec}.ts",
"**/*.stories.vue"
],
"rules": {
"no-console": "allow"
}
}
]
} }

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
packages/registry-types/src/comfyRegistryTypes.ts
src/types/generatedManagerTypes.ts

11
.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none",
"printWidth": 80,
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"]
}

View File

@@ -1,17 +0,0 @@
# Storybook Guidelines
See `@docs/guidance/storybook.md` for story patterns (auto-loaded for `*.stories.ts`).
## Available Context
Stories have access to:
- All ComfyUI stores
- PrimeVue with ComfyUI theming
- i18n system
- CSS variables and styling
## Troubleshooting
1. **Import Errors**: Verify `@/` alias works
2. **Missing Styles**: Check CSS imports in `preview.ts`
3. **Store Errors**: Check store initialization in setup

View File

@@ -1,3 +1,197 @@
<!-- Though standards bloom in open fields so wide, # Storybook Development Guidelines for Claude
Anthropic walks a path of lonely pride. -->
@AGENTS.md ## Quick Commands
- `pnpm storybook`: Start Storybook development server
- `pnpm build-storybook`: Build static Storybook
- `pnpm test:unit`: Run unit tests (includes Storybook components)
## Development Workflow for Storybook
1. **Creating New Stories**:
- Place `*.stories.ts` files alongside components
- Follow the naming pattern: `ComponentName.stories.ts`
- Use realistic mock data that matches ComfyUI schemas
2. **Testing Stories**:
- Verify stories render correctly in Storybook UI
- Test different component states and edge cases
- Ensure proper theming and styling
3. **Code Quality**:
- Run `pnpm typecheck` to verify TypeScript
- Run `pnpm lint` to check for linting issues
- Follow existing story patterns and conventions
## Story Creation Guidelines
### Basic Story Structure
```typescript
import type { Meta, StoryObj } from '@storybook/vue3'
import ComponentName from './ComponentName.vue'
const meta: Meta<typeof ComponentName> = {
title: 'Category/ComponentName',
component: ComponentName,
parameters: {
layout: 'centered' // or 'fullscreen', 'padded'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
// Component props
}
}
```
### Mock Data Patterns
For ComfyUI components, use realistic mock data:
```typescript
// Node definition mock
const mockNodeDef = {
input: {
required: {
prompt: ["STRING", { multiline: true }]
}
},
output: ["CONDITIONING"],
output_is_list: [false],
category: "conditioning"
}
// Component instance mock
const mockComponent = {
id: "1",
type: "CLIPTextEncode",
// ... other properties
}
```
### Common Story Variants
Always include these story variants when applicable:
- **Default**: Basic component with minimal props
- **WithData**: Component with realistic data
- **Loading**: Component in loading state
- **Error**: Component with error state
- **LongContent**: Component with edge case content
- **Empty**: Component with no data
### Storybook-Specific Code Patterns
#### Store Access
```typescript
// In stories, access stores through the setup function
export const WithStore: Story = {
render: () => ({
setup() {
const store = useMyStore()
return { store }
},
template: '<MyComponent :data="store.data" />'
})
}
```
#### Event Testing
```typescript
export const WithEvents: Story = {
args: {
onUpdate: fn() // Use Storybook's fn() for action logging
}
}
```
## Configuration Notes
### Vue App Setup
The Storybook preview is configured with:
- Pinia stores initialized
- PrimeVue with ComfyUI theme
- i18n internationalization
- All necessary CSS imports
### Build Configuration
- Vite integration with proper alias resolution
- Manual chunking for better performance
- TypeScript support with strict checking
- CSS processing for Vue components
## Troubleshooting
### Common Issues
1. **Import Errors**: Verify `@/` alias is working correctly
2. **Missing Styles**: Ensure CSS imports are in `preview.ts`
3. **Store Errors**: Check store initialization in setup
4. **Type Errors**: Use proper TypeScript types for story args
### Debug Commands
```bash
# Check TypeScript issues
pnpm typecheck
# Lint Storybook files
pnpm lint .storybook/
# Build to check for production issues
pnpm build-storybook
```
## File Organization
```
.storybook/
├── main.ts # Core configuration
├── preview.ts # Global setup and decorators
├── README.md # User documentation
└── CLAUDE.md # This file - Claude guidelines
src/
├── components/
│ └── MyComponent/
│ ├── MyComponent.vue
│ └── MyComponent.stories.ts
```
## Integration with ComfyUI
### Available Context
Stories have access to:
- All ComfyUI stores (widgetStore, colorPaletteStore, etc.)
- PrimeVue components with ComfyUI theming
- Internationalization system
- ComfyUI CSS variables and styling
### Testing Components
When testing ComfyUI-specific components:
1. Use realistic node definitions and data structures
2. Test with different node types (sampling, conditioning, etc.)
3. Verify proper CSS theming and dark/light modes
4. Check component behavior with various input combinations
### Performance Considerations
- Use manual chunking for large dependencies
- Minimize bundle size by avoiding unnecessary imports
- Leverage Storybook's lazy loading capabilities
- Profile build times and optimize as needed
## Best Practices
1. **Keep Stories Focused**: Each story should demonstrate one specific use case
2. **Use Descriptive Names**: Story names should clearly indicate what they show
3. **Document Complex Props**: Use JSDoc comments for complex prop types
4. **Test Edge Cases**: Create stories for unusual but valid use cases
5. **Maintain Consistency**: Follow established patterns in existing stories

View File

@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'], addons: ['@storybook/addon-docs'],
framework: { framework: {
name: '@storybook/vue3-vite', name: '@storybook/vue3-vite',
options: {} options: {}
@@ -69,42 +69,19 @@ const config: StorybookConfig = {
allowedHosts: true allowedHosts: true
}, },
resolve: { resolve: {
alias: [ alias: {
{ '@': process.cwd() + '/src'
find: '@/composables/queue/useJobList', }
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts' },
}, esbuild: {
{ // Prevent minification of identifiers to preserve _sfc_main
find: '@/composables/queue/useJobActions', minifyIdentifiers: false,
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts' keepNames: true
},
{
find: '@/utils/formatUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/formatUtil.ts'
},
{
find: '@/utils/networkUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/networkUtil.ts'
},
{
find: '@',
replacement: process.cwd() + '/src'
}
]
}, },
build: { build: {
rolldownOptions: { rollupOptions: {
experimental: { // Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
strictExecutionOrder: true
},
treeshake: false, treeshake: false,
output: {
keepNames: true
},
onwarn: (warning, warn) => { onwarn: (warning, warn) => {
// Suppress specific warnings // Suppress specific warnings
if ( if (

View File

@@ -9,9 +9,9 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice' import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip' import Tooltip from 'primevue/tooltip'
import '@/assets/css/style.css'
import { i18n } from '@/i18n' import { i18n } from '@/i18n'
import '@/lib/litegraph/public/css/litegraph.css' import '@/lib/litegraph/public/css/litegraph.css'
import '@/assets/css/style.css'
const ComfyUIPreset = definePreset(Aura, { const ComfyUIPreset = definePreset(Aura, {
semantic: { semantic: {
@@ -58,7 +58,6 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
document.documentElement.classList.remove('dark-theme') document.documentElement.classList.remove('dark-theme')
document.body.classList.remove('dark-theme') document.body.classList.remove('dark-theme')
} }
document.body.classList.add('[&_*]:!font-inter')
return Story(context.args, context) return Story(context.args, context)
} }

View File

@@ -12,9 +12,6 @@
"declaration-property-value-no-unknown": [ "declaration-property-value-no-unknown": [
true, true,
{ {
"typesSyntax": {
"radial-gradient()": "| <any-value>"
},
"ignoreProperties": { "ignoreProperties": {
"speak": ["none"], "speak": ["none"],
"app-region": ["drag", "no-drag"], "app-region": ["drag", "no-drag"],
@@ -59,7 +56,10 @@
"function-no-unknown": [ "function-no-unknown": [
true, true,
{ {
"ignoreFunctions": ["theme", "v-bind"] "ignoreFunctions": [
"theme",
"v-bind"
]
} }
] ]
}, },

View File

@@ -1,22 +1,25 @@
{ {
"recommendations": [ "recommendations": [
"antfu.vite",
"austenc.tailwind-docs", "austenc.tailwind-docs",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint", "davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"donjayamanne.githistory",
"eamodio.gitlens", "eamodio.gitlens",
"esbenp.prettier-vscode",
"figma.figma-vscode-extension",
"github.vscode-github-actions", "github.vscode-github-actions",
"github.vscode-pull-request-github", "github.vscode-pull-request-github",
"hbenl.vscode-test-explorer", "hbenl.vscode-test-explorer",
"kisstkondoros.vscode-codemetrics",
"lokalise.i18n-ally", "lokalise.i18n-ally",
"ms-playwright.playwright", "ms-playwright.playwright",
"oxc.oxc-vscode",
"sonarsource.sonarlint-vscode",
"vitest.explorer", "vitest.explorer",
"vue.volar", "vue.volar",
"wix.vscode-import-cost" "sonarsource.sonarlint-vscode",
"deque-systems.vscode-axe-linter",
"kisstkondoros.vscode-codemetrics",
"donjayamanne.githistory",
"wix.vscode-import-cost",
"prograhammer.tslint-vue",
"antfu.vite"
] ]
} }

View File

@@ -1,37 +1,38 @@
# ComfyUI Frontend # Repository Guidelines
Vue 3.5+ / TypeScript / Tailwind 4 frontend for ComfyUI. Uses Nx monorepo with pnpm. ## Project Structure & Module Organization
- Source: `src/` (Vue 3 + TypeScript). Key areas: `components/`, `views/`, `stores/` (Pinia), `composables/`, `services/`, `utils/`, `assets/`, `locales/`.
- Routing/i18n/entry: `src/router.ts`, `src/i18n.ts`, `src/main.ts`.
- Tests: unit/component in `tests-ui/` and `src/components/**/*.{test,spec}.ts`; E2E in `browser_tests/`.
- Public assets: `public/`. Build output: `dist/`.
- Config: `vite.config.mts`, `vitest.config.ts`, `playwright.config.ts`, `eslint.config.ts`, `.prettierrc`.
## Commands ## Build, Test, and Development Commands
- `pnpm dev`: Start Vite dev server.
- `pnpm dev:electron`: Dev server with Electron API mocks.
- `pnpm build`: Type-check then production build to `dist/`.
- `pnpm preview`: Preview the production build locally.
- `pnpm test:unit`: Run Vitest unit tests.
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`).
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint). `pnpm format` / `format:check`: Prettier.
- `pnpm typecheck`: Vue TSC type checking.
```bash ## Coding Style & Naming Conventions
pnpm dev # Vite dev server - Language: TypeScript, Vue SFCs (`.vue`). Indent 2 spaces; single quotes; no semicolons; width 80 (see `.prettierrc`).
pnpm build # Type-check + production build - Imports: sorted/grouped by plugin; run `pnpm format` before committing.
pnpm typecheck # Vue TSC - ESLint: Vue + TS rules; no floating promises; unused imports disallowed; i18n raw text restrictions in templates.
pnpm lint # ESLint - Naming: Vue components in PascalCase (e.g., `MenuHamburger.vue`); composables `useXyz.ts`; Pinia stores `*Store.ts`.
pnpm format # oxfmt
pnpm test:unit # Vitest
pnpm test:browser # Playwright E2E
pnpm knip # Dead code detection
```
## Key Conventions ## Testing Guidelines
- Frameworks: Vitest (unit/component, happy-dom) and Playwright (E2E).
- Test files: `**/*.{test,spec}.{ts,tsx,js}` under `tests-ui/`, `src/components/`, and `src/lib/litegraph/test/`.
- Coverage: text/json/html reporters enabled; aim to cover critical logic and new features.
- Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config.
See `docs/guidance/*.md` for file-specific rules. Quick reference: ## Commit & Pull Request Guidelines
- Commits: Use `[skip ci]` for locale-only updates when appropriate.
- PRs: Include clear description, linked issues (`- Fixes #123`), and screenshots/GIFs for UI changes.
- Quality gates: `pnpm lint`, `pnpm typecheck`, and relevant tests must pass. Keep PRs focused and small.
- **Vue**: Composition API only, `<script setup lang="ts">`, reactive props destructuring ## Security & Configuration Tips
- **Styling**: Tailwind only (no `<style>` blocks), use `cn()` for conditional classes - Secrets: Use `.env` (see `.env_example`); do not commit secrets.
- **Types**: No `any`, no `as any`, fix type issues at the source
- **i18n**: All strings via vue-i18n, entries in `src/locales/en/main.json`
- **Tests**: Colocated `*.test.ts` files, behavioral coverage
## Quality Gates
Before committing: `pnpm typecheck && pnpm lint && pnpm format`
## Agent Workspace
- Planning docs: `/temp/plans/`
- Scripts: `/temp/scripts/`
- Summaries: `/temp/summaries/`
- In-progress work: `/temp/in_progress/`

137
CLAUDE.md
View File

@@ -1 +1,136 @@
@AGENTS.md # ComfyUI Frontend Project Guidelines
## Repository Setup
For first-time setup, use the Claude command:
```
/setup_repo
```
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
**Prerequisites:** Node.js >= 24, Git repository, available ports (5173, 6006)
## Quick Commands
- `pnpm`: See all available commands
- `pnpm dev`: Start development server (port 5173, via nx)
- `pnpm typecheck`: Type checking
- `pnpm build`: Build for production (via nx)
- `pnpm lint`: Linting (via nx)
- `pnpm oxlint`: Fast Rust-based linting with Oxc
- `pnpm format`: Prettier formatting
- `pnpm test:unit`: Run all unit tests
- `pnpm test:browser`: Run E2E tests via Playwright
- `pnpm test:unit -- tests-ui/tests/example.test.ts`: Run single test file
- `pnpm storybook`: Start Storybook development server (port 6006)
- `pnpm knip`: Detect unused code and dependencies
## Monorepo Architecture
The project now uses **Nx** for build orchestration and task management:
- **Task Orchestration**: Commands like `dev`, `build`, `lint`, and `test:browser` run via Nx
- **Caching**: Nx provides intelligent caching for faster rebuilds
- **Configuration**: Managed through `nx.json` with plugins for ESLint, Storybook, Vite, and Playwright
- **Dependencies**: Nx handles dependency graph analysis and parallel execution
Key Nx features:
- Build target caching and incremental builds
- Parallel task execution across the monorepo
- Plugin-based architecture for different tools
## Development Workflow
1. **First-time setup**: Run `/setup_repo` Claude command
2. Make code changes
3. Run tests (see subdirectory CLAUDE.md files)
4. Run typecheck, lint, format
5. Check README updates
6. Consider docs.comfy.org updates
## Git Conventions
- Use [prefix] format: [feat], [bugfix], [docs]
- Add "Fixes #n" to PR descriptions
- Never mention Claude/AI in commits
## External Resources
- PrimeVue docs: <https://primevue.org>
- ComfyUI docs: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
## Project Philosophy
- Follow good software engineering principles
- YAGNI
- AHA
- DRY
- SOLID
- Clean, stable public APIs
- Domain-driven design
- Thousands of users and extensions
- Prioritize clean interfaces that restrict extension access
## Repository Navigation
- Check README files in key folders (tests-ui, browser_tests, composables, etc.)
- Prefer running single tests for performance
- Use --help for unfamiliar CLI tools
## GitHub Integration
When referencing Comfy-Org repos:
1. Check for local copy
2. Use GitHub API for branches/PRs/metadata
3. Curl GitHub website if needed
## Settings and Feature Flags Quick Reference
### Settings Usage
```typescript
const settingStore = useSettingStore()
const value = settingStore.get('Comfy.SomeSetting') // Get setting
await settingStore.set('Comfy.SomeSetting', newValue) // Update setting
```
### Dynamic Defaults
```typescript
{
id: 'Comfy.Example.Setting',
defaultValue: () => window.innerWidth < 1024 ? 'small' : 'large' // Runtime context
}
```
### Version-Based Defaults
```typescript
{
id: 'Comfy.Example.Feature',
defaultValue: 'legacy',
defaultsByInstallVersion: { '1.25.0': 'enhanced' } // Gradual rollout
}
```
### Feature Flags
```typescript
if (api.serverSupportsFeature('feature_name')) { // Check capability
// Use enhanced feature
}
const value = api.getServerFeature('config_name', defaultValue) // Get config
```
**Documentation:**
- Settings system: `docs/SETTINGS.md`
- Feature flags system: `docs/FEATURE_FLAGS.md`
## Common Pitfalls
- NEVER use `any` type - use proper TypeScript types
- NEVER use `as any` type assertions - fix the underlying type issue
- NEVER use `--no-verify` flag when committing
- NEVER delete or disable tests to make them pass
- NEVER circumvent quality checks
- NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the `style.css` theme, e.g. `bg-node-component-surface`
- NEVER use `:class="[]"` to merge class names - always use `import { cn } from '@/utils/tailwindUtil'`, for example: `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`

View File

@@ -25,9 +25,6 @@
# Link rendering # Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu /src/renderer/core/canvas/links/ @benceruleanlu
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
# Node help system # Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu /src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu /src/stores/workspace/nodeHelpStore.ts @benceruleanlu
@@ -37,7 +34,7 @@
/src/components/graph/selectionToolbox/ @Myestery /src/components/graph/selectionToolbox/ @Myestery
# Minimap # Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery /src/renderer/extensions/minimap/ @jtydhr88 @Myestery
# Workflow Templates # Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki /src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
@@ -46,6 +43,7 @@
# Mask Editor # Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp /src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp /src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp
# 3D # 3D
/src/extensions/core/load3d.ts @jtydhr88 /src/extensions/core/load3d.ts @jtydhr88
@@ -55,7 +53,8 @@
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata /src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Translations # Translations
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs /src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
# LLM Instructions (blank on purpose) # LLM Instructions (blank on purpose)
.claude/ .claude/

View File

@@ -17,9 +17,17 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
### Prerequisites & Technology Stack ### Prerequisites & Technology Stack
- **Required Software**: - **Required Software**:
- Node.js (v24) and pnpm - Node.js (v18 or later to build; v24 for vite dev server) and pnpm
- Git for version control - Git for version control
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`) - A running ComfyUI backend instance
- **Tech Stack**:
- [Vue 3.5 Composition API](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- litegraph.js (integrated in src/lib) for node editor
- [zod](https://zod.dev/) for schema validation
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
### Initial Setup ### Initial Setup
@@ -47,18 +55,15 @@ To launch ComfyUI and have it connect to your development server:
python main.py --port 8188 python main.py --port 8188
``` ```
If you are on Mac or a low-spec machine, you can run the server in CPU mode ### Git pre-commit hooks
```bash Run `pnpm prepare` to install Git pre-commit hooks. Currently, the pre-commit hook is used to auto-format code on commit.
python main.py --port 8188 --cpu
```
### Dev Server ### Dev Server
- Start local ComfyUI backend at `localhost:8188` - Start local ComfyUI backend at `localhost:8188`
- Run `pnpm dev` to start the dev server - Run `pnpm dev` to start the dev server
- Run `pnpm dev:electron` to start the dev server with electron API mocked - Run `pnpm dev:electron` to start the dev server with electron API mocked
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)
#### Access dev server on touch devices #### Access dev server on touch devices
@@ -108,7 +113,7 @@ When you fix a bug that affects a version in feature freeze, we use an automated
1. Create your PR fixing the bug on `main` branch as usual 1. Create your PR fixing the bug on `main` branch as usual
2. Before merging, add these labels to your PR: 2. Before merging, add these labels to your PR:
- `needs-backport` - triggers the automated backport workflow - `needs-backport` - triggers the automated backport workflow
- `core/1.24` - targets the `core/1.24` release candidate branch - `1.24` - targets the `core/1.24` release candidate branch
3. Merge your PR normally 3. Merge your PR normally
4. The automated workflow will: 4. The automated workflow will:
@@ -140,6 +145,67 @@ This project includes `.vscode/launch.json.default` and `.vscode/settings.json.d
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page. We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
### MCP Integrations
#### Playwright Browser Automation
The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.
For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.
##### Setup for Claude Code
After installing dependencies with `pnpm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.
Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):
```
> navigate to localhost:5173 and take a screenshot.
tell me the items in the menubar
● I'll navigate to localhost:5173 and take a
screenshot to see the menubar items.
● playwright:playwright_navigate (MCP)(url: …
"http://local
host:5173")
⎿ Navigated to http://localhost:5173
● playwright:playwright_screenshot (MCP)(name: "lo…
alhost_5173
_menubar")
⎿ Screenshot saved to: ../../../Downloads/localho
st_5173_menubar-2025-05-30T17-23-00-942Z.png
⎿ Screenshot also stored in memory with name:
'localhost_5173_menubar'
● Read(../../../Downloads/localhost_5173_menubar-2…
25-05-30T17-23-00-942Z.png)
⎿ Read image (94.3KB)
● Based on the screenshot, I can see the menubar at
the top of the ComfyUI interface contains the
following items:
Main Menubar Items:
- Workflow - Menu for workflow-related actions
- Edit - Menu for editing operations
- Help - Menu for help and documentation
Additional UI Elements:
- ComfyUI Logo - On the far left
- Workflow Tab - Shows "Unsaved Workflow" with a
dropdown and close button
- Layout Controls - On the far right (grid view
and hamburger menu icons)
The interface shows a typical ComfyUI workflow
graph with nodes like "Load Checkpoint", "CLIP
Text Encode (Prompt)", "KSampler", and "Empty
Latent Image" connected with colored cables.
```
## Testing ## Testing
### Unit Tests ### Unit Tests
@@ -149,7 +215,7 @@ We've also included a list of recommended extensions in `.vscode/extensions.json
### Playwright Tests ### Playwright Tests
Playwright tests verify the whole app. See [browser_tests/README.md](browser_tests/README.md) for details. The snapshots are generated in the GH actions runner, not locally. Playwright tests verify the whole app. See [browser_tests/README.md](browser_tests/README.md) for details.
### Running All Tests ### Running All Tests
@@ -157,6 +223,7 @@ Before submitting a PR, ensure all tests pass:
```bash ```bash
pnpm test:unit pnpm test:unit
pnpm test:browser
pnpm typecheck pnpm typecheck
pnpm lint pnpm lint
pnpm format pnpm format
@@ -165,32 +232,23 @@ pnpm format
## Code Style Guidelines ## Code Style Guidelines
### TypeScript ### TypeScript
- Use TypeScript for all new code - Use TypeScript for all new code
- Avoid `any` types - use proper type definitions - Avoid `any` types - use proper type definitions
- Never use `@ts-expect-error` - fix the underlying type issue - Never use `@ts-expect-error` - fix the underlying type issue
### Vue 3 Patterns ### Vue 3 Patterns
- Use Composition API for all components - Use Composition API for all components
- Follow Vue 3.5+ patterns (props destructuring is reactive) - Follow Vue 3.5+ patterns (props destructuring is reactive)
- Use `<script setup>` syntax - Use `<script setup>` syntax
### Styling ### Styling
- Use Tailwind CSS classes instead of custom CSS - Use Tailwind CSS classes instead of custom CSS
- NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the [style.css](packages/design-system/src/css/style.css) like `bg-node-component-surface` - NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the `style.css` theme, e.g. `bg-node-component-surface`
## Design Team Approval (Required for Notable UI Changes)
Changes that materially affect the default UI must be approved or requested by our design team before they can be merged. This is generally a blocking requirement and applies to internal contributors and OSS contributors alike.
### Internationalization ### Internationalization
- All user-facing strings must use vue-i18n - All user-facing strings must use vue-i18n
- Add translations to [src/locales/en/main.json](src/locales/en/main.json) - Add translations to `src/locales/en/main.json`
- Use translation keys: `const { t } = useI18n(); t('key.path')` - Use translation keys: `const { t } = useI18n(); t('key.path')`
- The corresponding values in other locales is generated automatically on releases, PR authors only need to edit [src/locales/en/main.json](src/locales/en/main.json)
## Icons ## Icons
@@ -224,12 +282,34 @@ The original litegraph repository (https://github.com/Comfy-Org/litegraph.js) is
2. Run all tests and ensure they pass 2. Run all tests and ensure they pass
3. Create a pull request with a clear title and description 3. Create a pull request with a clear title and description
4. Use conventional commit format for PR titles: 4. Use conventional commit format for PR titles:
- `feat:` for new features - `[feat]` for new features
- `fix:` for bug fixes - `[fix]` for bug fixes
- `docs:` for documentation - `[docs]` for documentation
- `refactor:` for code refactoring - `[refactor]` for code refactoring
- `test:` for test additions/changes - `[test]` for test additions/changes
- `chore:` for maintenance tasks - `[chore]` for maintenance tasks
### PR Description Template
```
## Description
Brief description of the changes
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Testing
- [ ] Unit tests pass
- [ ] Component tests pass
- [ ] Browser tests pass (if applicable)
- [ ] Manual testing completed
## Screenshots (if applicable)
Add screenshots for UI changes
```
### Review Process ### Review Process
@@ -245,4 +325,4 @@ If you have questions about contributing:
- Ask in our [Discord](https://discord.com/invite/comfyorg) - Ask in our [Discord](https://discord.com/invite/comfyorg)
- Open a new issue for clarification - Open a new issue for clarification
Thank you for contributing to the ComfyUI Frontend! Thank you for contributing to ComfyUI Frontend!

View File

@@ -33,11 +33,11 @@
The project follows a structured release process for each minor version, consisting of three distinct phases: The project follows a structured release process for each minor version, consisting of three distinct phases:
1. **Development Phase** - 2 weeks 1. **Development Phase** - 1 week
- Active development of new features - Active development of new features
- Code changes merged to the development branch - Code changes merged to the development branch
2. **Feature Freeze** - 2 weeks 2. **Feature Freeze** - 1 week
- No new features accepted - No new features accepted
- Only bug fixes are cherry-picked to the release branch - Only bug fixes are cherry-picked to the release branch
- Testing and stabilization of the codebase - Testing and stabilization of the codebase
@@ -56,16 +56,16 @@ To use the latest nightly release, add the following command line argument to yo
``` ```
## Overlapping Release Cycles ## Overlapping Release Cycles
The development of successive minor versions overlaps. For example, while version 1.1 is in feature freeze, development for version 1.2 begins simultaneously. Each feature has approximately 4 weeks from merge to ComfyUI stable release (2 weeks on main, 2 weeks frozen on RC). The development of successive minor versions overlaps. For example, while version 1.1 is in feature freeze, development for version 1.2 begins simultaneously.
### Example Release Cycle ### Example Release Cycle
| Week | Date Range | Version 1.1 | Version 1.2 | Version 1.3 | Patch Releases | | Week | Date Range | Version 1.1 | Version 1.2 | Version 1.3 | Patch Releases |
|------|------------|-------------|-------------|-------------|----------------| |------|------------|-------------|-------------|-------------|----------------|
| 1-2 | Mar 1-14 | Development | - | - | - | | 1 | Mar 1-7 | Development | - | - | - |
| 3-4 | Mar 15-28 | Feature Freeze | Development | - | 1.1.0 through 1.1.13 (daily) | | 2 | Mar 8-14 | Feature Freeze | Development | - | 1.1.0 through 1.1.6 (daily) |
| 5-6 | Mar 29-Apr 11 | Released | Feature Freeze | Development | 1.1.14+ (daily)<br>1.2.0 through 1.2.13 (daily) | | 3 | Mar 15-21 | Released | Feature Freeze | Development | 1.1.7 through 1.1.13 (daily)<br>1.2.0 through 1.2.6 (daily) |
| 7-8 | Apr 12-25 | - | Released | Feature Freeze | 1.2.14+ (daily)<br>1.3.0 through 1.3.13 (daily) | | 4 | Mar 22-28 | - | Released | Feature Freeze | 1.2.7 through 1.2.13 (daily)<br>1.3.0 through 1.3.6 (daily) |
## Release Summary ## Release Summary

View File

@@ -1 +0,0 @@
AMD and the AMD Arrow logo are trademarks of Advanced Micro Devices, Inc.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@comfyorg/desktop-ui", "name": "@comfyorg/desktop-ui",
"version": "0.0.6", "version": "0.0.4",
"type": "module", "type": "module",
"nx": { "nx": {
"tags": [ "tags": [
@@ -87,8 +87,6 @@
} }
}, },
"scripts": { "scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"storybook": "storybook dev -p 6007", "storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook" "build-storybook": "storybook build -o dist/storybook"
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,84 +0,0 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import type {
ElectronAPI,
TorchDeviceType
} from '@comfyorg/comfyui-electron-types'
import { ref } from 'vue'
import GpuPicker from './GpuPicker.vue'
type Platform = ReturnType<ElectronAPI['getPlatform']>
type ElectronAPIStub = Pick<ElectronAPI, 'getPlatform'>
type WindowWithElectron = Window & { electronAPI?: ElectronAPIStub }
const meta: Meta<typeof GpuPicker> = {
title: 'Desktop/Components/GpuPicker',
component: GpuPicker,
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
}
}
export default meta
type Story = StoryObj<typeof meta>
function createElectronDecorator(platform: Platform) {
function getPlatform() {
return platform
}
return function ElectronDecorator() {
const windowWithElectron = window as WindowWithElectron
windowWithElectron.electronAPI = { getPlatform }
return { template: '<story />' }
}
}
function renderWithDevice(device: TorchDeviceType | null) {
return function Render() {
return {
components: { GpuPicker },
setup() {
const selected = ref<TorchDeviceType | null>(device)
return { selected }
},
template: `
<div class="min-h-screen bg-neutral-950 p-8">
<GpuPicker v-model:device="selected" />
</div>
`
}
}
}
const windowsDecorator = createElectronDecorator('win32')
const macDecorator = createElectronDecorator('darwin')
export const WindowsNvidiaSelected: Story = {
decorators: [windowsDecorator],
render: renderWithDevice('nvidia')
}
export const WindowsAmdSelected: Story = {
decorators: [windowsDecorator],
render: renderWithDevice('amd')
}
export const WindowsCpuSelected: Story = {
decorators: [windowsDecorator],
render: renderWithDevice('cpu')
}
export const MacMpsSelected: Story = {
decorators: [macDecorator],
render: renderWithDevice('mps')
}

View File

@@ -11,32 +11,29 @@
<!-- Apple Metal / NVIDIA --> <!-- Apple Metal / NVIDIA -->
<HardwareOption <HardwareOption
v-if="platform === 'darwin'" v-if="platform === 'darwin'"
image-path="./assets/images/apple-mps-logo.png" :image-path="'./assets/images/apple-mps-logo.png'"
placeholder-text="Apple Metal" placeholder-text="Apple Metal"
subtitle="Apple Metal" subtitle="Apple Metal"
:value="'mps'"
:selected="selected === 'mps'" :selected="selected === 'mps'"
:recommended="true"
@click="pickGpu('mps')" @click="pickGpu('mps')"
/> />
<template v-else> <HardwareOption
<HardwareOption v-else
image-path="./assets/images/nvidia-logo-square.jpg" :image-path="'./assets/images/nvidia-logo-square.jpg'"
placeholder-text="NVIDIA" placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')" :subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:selected="selected === 'nvidia'" :value="'nvidia'"
@click="pickGpu('nvidia')" :selected="selected === 'nvidia'"
/> :recommended="true"
<HardwareOption @click="pickGpu('nvidia')"
image-path="./assets/images/amd-rocm-logo.png" />
placeholder-text="AMD"
:subtitle="$t('install.gpuPicker.amdSubtitle')"
:selected="selected === 'amd'"
@click="pickGpu('amd')"
/>
</template>
<!-- CPU --> <!-- CPU -->
<HardwareOption <HardwareOption
placeholder-text="CPU" placeholder-text="CPU"
:subtitle="$t('install.gpuPicker.cpuSubtitle')" :subtitle="$t('install.gpuPicker.cpuSubtitle')"
:value="'cpu'"
:selected="selected === 'cpu'" :selected="selected === 'cpu'"
@click="pickGpu('cpu')" @click="pickGpu('cpu')"
/> />
@@ -44,6 +41,7 @@
<HardwareOption <HardwareOption
placeholder-text="Manual Install" placeholder-text="Manual Install"
:subtitle="$t('install.gpuPicker.manualSubtitle')" :subtitle="$t('install.gpuPicker.manualSubtitle')"
:value="'unsupported'"
:selected="selected === 'unsupported'" :selected="selected === 'unsupported'"
@click="pickGpu('unsupported')" @click="pickGpu('unsupported')"
/> />
@@ -83,15 +81,13 @@ const selected = defineModel<TorchDeviceType | null>('device', {
const electron = electronAPI() const electron = electronAPI()
const platform = electron.getPlatform() const platform = electron.getPlatform()
const recommendedDevices: TorchDeviceType[] = ['mps', 'nvidia', 'amd'] const showRecommendedBadge = computed(
const showRecommendedBadge = computed(() => () => selected.value === 'mps' || selected.value === 'nvidia'
selected.value ? recommendedDevices.includes(selected.value) : false
) )
const descriptionKeys = { const descriptionKeys = {
mps: 'appleMetal', mps: 'appleMetal',
nvidia: 'nvidia', nvidia: 'nvidia',
amd: 'amd',
cpu: 'cpu', cpu: 'cpu',
unsupported: 'manual' unsupported: 'manual'
} as const } as const
@@ -101,7 +97,7 @@ const descriptionText = computed(() => {
return st(`install.gpuPicker.${key}Description`, '') return st(`install.gpuPicker.${key}Description`, '')
}) })
function pickGpu(value: TorchDeviceType) { const pickGpu = (value: TorchDeviceType) => {
selected.value = value selected.value = value
} }
</script> </script>

View File

@@ -29,6 +29,7 @@ export const AppleMetalSelected: Story = {
imagePath: '/assets/images/apple-mps-logo.png', imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal', placeholderText: 'Apple Metal',
subtitle: 'Apple Metal', subtitle: 'Apple Metal',
value: 'mps',
selected: true selected: true
} }
} }
@@ -38,6 +39,7 @@ export const AppleMetalUnselected: Story = {
imagePath: '/assets/images/apple-mps-logo.png', imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal', placeholderText: 'Apple Metal',
subtitle: 'Apple Metal', subtitle: 'Apple Metal',
value: 'mps',
selected: false selected: false
} }
} }
@@ -46,6 +48,7 @@ export const CPUOption: Story = {
args: { args: {
placeholderText: 'CPU', placeholderText: 'CPU',
subtitle: 'Subtitle', subtitle: 'Subtitle',
value: 'cpu',
selected: false selected: false
} }
} }
@@ -54,6 +57,7 @@ export const ManualInstall: Story = {
args: { args: {
placeholderText: 'Manual Install', placeholderText: 'Manual Install',
subtitle: 'Subtitle', subtitle: 'Subtitle',
value: 'unsupported',
selected: false selected: false
} }
} }
@@ -63,6 +67,7 @@ export const NvidiaSelected: Story = {
imagePath: '/assets/images/nvidia-logo-square.jpg', imagePath: '/assets/images/nvidia-logo-square.jpg',
placeholderText: 'NVIDIA', placeholderText: 'NVIDIA',
subtitle: 'NVIDIA', subtitle: 'NVIDIA',
value: 'nvidia',
selected: true selected: true
} }
} }

View File

@@ -36,13 +36,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
interface Props { interface Props {
imagePath?: string imagePath?: string
placeholderText: string placeholderText: string
subtitle?: string subtitle?: string
value: TorchDeviceType
selected?: boolean selected?: boolean
recommended?: boolean
} }
defineProps<Props>() defineProps<Props>()

View File

@@ -40,8 +40,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PassThrough } from '@primevue/core' import type { PassThrough } from '@primevue/core'
import Button from 'primevue/button' import Button from 'primevue/button'
import Step from 'primevue/step' import Step, { type StepPassThroughOptions } from 'primevue/step'
import type { StepPassThroughOptions } from 'primevue/step'
import StepList from 'primevue/steplist' import StepList from 'primevue/steplist'
defineProps<{ defineProps<{
@@ -51,6 +50,8 @@ defineProps<{
canProceed: boolean canProceed: boolean
/** Whether the location step should be disabled */ /** Whether the location step should be disabled */
disableLocationStep: boolean disableLocationStep: boolean
/** Whether the migration step should be disabled */
disableMigrationStep: boolean
/** Whether the settings step should be disabled */ /** Whether the settings step should be disabled */
disableSettingsStep: boolean disableSettingsStep: boolean
}>() }>()

View File

@@ -104,8 +104,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types' import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import { isInChina } from '@comfyorg/shared-frontend-utils/networkUtil' import { isInChina } from '@comfyorg/shared-frontend-utils/networkUtil'
import Accordion from 'primevue/accordion' import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent' import AccordionContent from 'primevue/accordioncontent'
@@ -155,7 +155,7 @@ const activeAccordionIndex = ref<string[] | undefined>(undefined)
const electron = electronAPI() const electron = electronAPI()
// Mirror configuration logic // Mirror configuration logic
function getTorchMirrorItem(device: TorchDeviceType): UVMirror { const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror' const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) { switch (device) {
case 'mps': case 'mps':
@@ -170,7 +170,6 @@ function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
mirror: TorchMirrorUrl.Cuda, mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda fallbackMirror: TorchMirrorUrl.Cuda
} }
case 'amd':
case 'cpu': case 'cpu':
default: default:
return { return {

View File

@@ -63,6 +63,7 @@ const taskStore = useMaintenanceTaskStore()
defineProps<{ defineProps<{
displayAsList: string displayAsList: string
filter: MaintenanceFilter filter: MaintenanceFilter
isRefreshing: boolean
}>() }>()
const executeTask = async (task: MaintenanceTask) => { const executeTask = async (task: MaintenanceTask) => {

View File

@@ -1,13 +1,13 @@
// Import only English locale eagerly as the default/fallback // Import only English locale eagerly as the default/fallback
// ESLint cannot statically resolve dynamic imports with path aliases (@frontend-locales/*), // ESLint cannot statically resolve dynamic imports with path aliases (@frontend-locales/*),
// but these are properly configured in tsconfig.json and resolved by Vite at build time. // but these are properly configured in tsconfig.json and resolved by Vite at build time.
// eslint-disable-next-line import-x/no-unresolved
import enCommands from '@frontend-locales/en/commands.json' with { type: 'json' } import enCommands from '@frontend-locales/en/commands.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import en from '@frontend-locales/en/main.json' with { type: 'json' } import en from '@frontend-locales/en/main.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' } import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' } import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
@@ -27,7 +27,7 @@ function buildLocale<
// Locale loader map - dynamically import locales only when needed // Locale loader map - dynamically import locales only when needed
// ESLint cannot statically resolve these dynamic imports, but they are valid at build time // ESLint cannot statically resolve these dynamic imports, but they are valid at build time
/* eslint-disable import-x/no-unresolved */
const localeLoaders: Record< const localeLoaders: Record<
string, string,
() => Promise<{ default: Record<string, unknown> }> () => Promise<{ default: Record<string, unknown> }>
@@ -155,14 +155,12 @@ export async function loadLocale(locale: string): Promise<void> {
} }
// Only include English in the initial bundle // Only include English in the initial bundle
const enMessages = buildLocale(en, enNodes, enCommands, enSettings) const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings)
}
// Type for locale messages - inferred from the English locale structure // Type for locale messages - inferred from the English locale structure
type LocaleMessages = typeof enMessages type LocaleMessages = typeof messages.en
const messages: Record<string, LocaleMessages> = {
en: enMessages
}
export const i18n = createI18n({ export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2 // Must set `false`, as Vue I18n Legacy API is for Vue 2

View File

@@ -1,6 +1,5 @@
import { useTimeout } from '@vueuse/core' import { useTimeout } from '@vueuse/core'
import { computed, ref, watch } from 'vue' import { type Ref, computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
/** /**
* Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}. * Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}.

View File

@@ -29,8 +29,7 @@ import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import Button from 'primevue/button' import Button from 'primevue/button'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { getDialog } from '@/constants/desktopDialogs' import { type DialogAction, getDialog } from '@/constants/desktopDialogs'
import type { DialogAction } from '@/constants/desktopDialogs'
import { t } from '@/i18n' import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'

View File

@@ -143,8 +143,6 @@ const goToPreviousStep = () => {
const electron = electronAPI() const electron = electronAPI()
const router = useRouter() const router = useRouter()
const install = async () => { const install = async () => {
if (!device.value) return
const options: InstallOptions = { const options: InstallOptions = {
installPath: installPath.value, installPath: installPath.value,
autoUpdate: autoUpdate.value, autoUpdate: autoUpdate.value,
@@ -154,6 +152,7 @@ const install = async () => {
pythonMirror: pythonMirror.value, pythonMirror: pythonMirror.value,
pypiMirror: pypiMirror.value, pypiMirror: pypiMirror.value,
torchMirror: torchMirror.value, torchMirror: torchMirror.value,
// @ts-expect-error fixme ts strict error
device: device.value device: device.value
} }
electron.installComfyUI(options) electron.installComfyUI(options)
@@ -167,11 +166,7 @@ onMounted(async () => {
if (!electron) return if (!electron) return
const detectedGpu = await electron.Config.getDetectedGpu() const detectedGpu = await electron.Config.getDetectedGpu()
if ( if (detectedGpu === 'mps' || detectedGpu === 'nvidia') {
detectedGpu === 'mps' ||
detectedGpu === 'nvidia' ||
detectedGpu === 'amd'
) {
device.value = detectedGpu device.value = detectedGpu
} }

View File

@@ -74,6 +74,7 @@
class="border-neutral-700 border-solid border-x-0 border-y" class="border-neutral-700 border-solid border-x-0 border-y"
:filter :filter
:display-as-list :display-as-list
:is-refreshing
/> />
<!-- Actions --> <!-- Actions -->

View File

@@ -5,7 +5,7 @@
<img <img
class="sad-girl" class="sad-girl"
src="/assets/images/sad_girl.png" src="/assets/images/sad_girl.png"
:alt="$t('notSupported.illustrationAlt')" alt="Sad girl illustration"
/> />
<div class="no-drag sad-text flex items-center"> <div class="no-drag sad-text flex items-center">

View File

@@ -64,7 +64,7 @@ export default defineConfig(() => {
}) })
], ],
build: { build: {
minify: SHOULD_MINIFY, minify: SHOULD_MINIFY ? ('esbuild' as const) : false,
target: 'es2022', target: 'es2022',
sourcemap: true sourcemap: true
} }

View File

@@ -1,8 +0,0 @@
# E2E Testing Guidelines
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
## Directory Structure
- `assets/` - Test data (JSON workflows, fixtures)
- Tests use premade JSON workflows to load desired graph state

View File

@@ -1,3 +1,17 @@
<!-- In gardens where the agents freely play, # E2E Testing Guidelines
One stubborn flower turns the other way. -->
@AGENTS.md ## Browser Tests
- Test user workflows
- Use Playwright fixtures
- Follow naming conventions
## Best Practices
- Check assets/ for test data
- Prefer specific selectors
- Test across viewports
## Testing Process
After code changes:
1. Create browser tests as appropriate
2. Run tests until passing
3. Then run typecheck, lint, format

View File

@@ -1,92 +0,0 @@
{
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
"revision": 0,
"last_node_id": 17,
"last_link_id": 9,
"nodes": [
{
"id": 17,
"type": "VAEDecode",
"pos": [
318.8446183157076,
355.3961392345528
],
"size": [
225,
102
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [],
"groups": [
{
"id": 4,
"title": "Outer Group",
"bounding": [
-46.25245366331014,
-150.82497138023245,
1034.4034361963616,
1007.338460439933
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Inner Group",
"bounding": [
80.96059074101554,
28.123757436778178,
718.286373661183,
691.2397164539732
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.7121393732101533,
"offset": [
289.18242848011835,
367.0747755524199
]
},
"frontendVersion": "1.35.5",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"workflowRendererVersion": "Vue"
},
"version": 0.4
}

View File

@@ -1,150 +0,0 @@
{
"id": "e0cb1d7e-5437-4911-b574-c9603dfbeaee",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "8bfe4227-f272-49e1-a892-0a972a86867c",
"pos": [
-317,
-336
],
"size": [
210,
58
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
[
"-1",
"batch_size"
]
]
},
"widgets_values": [
1
]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "8bfe4227-f272-49e1-a892-0a972a86867c",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [
-562,
-358,
120,
60
]
},
"outputNode": {
"id": -20,
"bounding": [
-52,
-358,
120,
40
]
},
"inputs": [
{
"id": "b4a8bc2a-8e9f-41aa-938d-c567a11d2c00",
"name": "batch_size",
"type": "INT",
"linkIds": [
1
],
"pos": [
-462,
-338
]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "EmptyLatentImage",
"pos": [
-382,
-376
],
"size": [
270,
106
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
512,
512,
1
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.35.1"
},
"version": 0.4
}

View File

@@ -1,412 +0,0 @@
{
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
"revision": 0,
"last_node_id": 16,
"last_link_id": 9,
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
60,
200
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
1
]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
3,
5
]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
8
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"cnr_id": "comfy-core",
"ver": "0.3.65",
"models": [
{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"directory": "checkpoints"
}
]
},
"widgets_values": [
"v1-5-pruned-emaonly-fp16.safetensors"
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
870,
170
],
"size": [
315,
474
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 1
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "KSampler",
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": [
685468484323813,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
975,
700
],
"size": [
210,
46
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": []
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
410,
410
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": [
"text, watermark"
],
"color": "#223",
"bgcolor": "#335"
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
520,
690
],
"size": [
315,
106
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage",
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": [
512,
512,
1
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
411.21649169921875,
203.68695068359375
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, purple galaxy bottle,"
],
"color": "#232",
"bgcolor": "#353"
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
0,
"CLIP"
],
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
5,
4,
1,
7,
0,
"CLIP"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
8,
4,
2,
8,
1,
"VAE"
]
],
"groups": [
{
"id": 1,
"title": "Step 1 - Load model",
"bounding": [
50,
130,
335,
181.60000610351562
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Step 3 - Image size",
"bounding": [
510,
620,
335,
189.60000610351562
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Step 2 - Prompt",
"bounding": [
400,
130,
445.27801513671875,
467.2060852050781
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.44218252181616574,
"offset": [
-666.5670907104311,
-2227.894644048147
]
},
"frontendVersion": "1.35.3",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"workflowRendererVersion": "LG"
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -3,7 +3,7 @@ import { test as base, expect } from '@playwright/test'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import * as fs from 'fs' import * as fs from 'fs'
import type { LGraphNode, LGraph } from '../../src/lib/litegraph/src/litegraph' import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph'
import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema' import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema'
import type { KeyCombo } from '../../src/schemas/keyBindingSchema' import type { KeyCombo } from '../../src/schemas/keyBindingSchema'
import type { useWorkspaceStore } from '../../src/stores/workspaceStore' import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
@@ -21,37 +21,24 @@ import {
import { Topbar } from './components/Topbar' import { Topbar } from './components/Topbar'
import type { Position, Size } from './types' import type { Position, Size } from './types'
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils' import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
import TaskHistory from './utils/taskHistory'
dotenv.config() dotenv.config()
type WorkspaceStore = ReturnType<typeof useWorkspaceStore> type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
class ComfyPropertiesPanel {
readonly root: Locator
readonly panelTitle: Locator
readonly searchBox: Locator
constructor(readonly page: Page) {
this.root = page.getByTestId('properties-panel')
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder('Search...')
}
}
class ComfyMenu { class ComfyMenu {
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null private _workflowsTab: WorkflowsSidebarTab | null = null
private _topbar: Topbar | null = null private _topbar: Topbar | null = null
public readonly sideToolbar: Locator public readonly sideToolbar: Locator
public readonly propertiesPanel: ComfyPropertiesPanel
public readonly themeToggleButton: Locator public readonly themeToggleButton: Locator
public readonly saveButton: Locator public readonly saveButton: Locator
constructor(public readonly page: Page) { constructor(public readonly page: Page) {
this.sideToolbar = page.locator('.side-tool-bar-container') this.sideToolbar = page.locator('.side-tool-bar-container')
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle') this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
this.propertiesPanel = new ComfyPropertiesPanel(page)
this.saveButton = page this.saveButton = page
.locator('button[title="Save the current workflow"]') .locator('button[title="Save the current workflow"]')
.nth(0) .nth(0)
@@ -109,42 +96,28 @@ type KeysOfType<T, Match> = {
}[keyof T] }[keyof T]
class ConfirmDialog { class ConfirmDialog {
private readonly root: Locator
public readonly delete: Locator public readonly delete: Locator
public readonly overwrite: Locator public readonly overwrite: Locator
public readonly reject: Locator public readonly reject: Locator
public readonly confirm: Locator public readonly confirm: Locator
constructor(public readonly page: Page) { constructor(public readonly page: Page) {
this.root = page.getByRole('dialog') this.delete = page.locator('button.p-button[aria-label="Delete"]')
this.delete = this.root.getByRole('button', { name: 'Delete' }) this.overwrite = page.locator('button.p-button[aria-label="Overwrite"]')
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' }) this.reject = page.locator('button.p-button[aria-label="Cancel"]')
this.reject = this.root.getByRole('button', { name: 'Cancel' }) this.confirm = page.locator('button.p-button[aria-label="Confirm"]')
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
} }
async click(locator: KeysOfType<ConfirmDialog, Locator>) { async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator] const loc = this[locator]
await expect(loc).toBeVisible() await expect(loc).toBeVisible()
await loc.click() await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() => window['app']?.extensionManager?.workflow?.isBusy === false,
undefined,
{ timeout: 3000 }
)
} }
} }
export class ComfyPage { export class ComfyPage {
private _history: TaskHistory | null = null
public readonly url: string public readonly url: string
// All canvas position operations are based on default view of canvas. // All canvas position operations are based on default view of canvas.
public readonly canvas: Locator public readonly canvas: Locator
@@ -255,9 +228,6 @@ export class ComfyPage {
await this.page.evaluate(async () => { await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows() await window['app'].extensionManager.workflow.syncWorkflows()
}) })
// Wait for Vue to re-render the workflow list
await this.nextFrame()
} }
async setupUser(username: string) { async setupUser(username: string) {
@@ -298,6 +268,11 @@ export class ComfyPage {
} }
} }
setupHistory(): TaskHistory {
this._history ??= new TaskHistory(this)
return this._history
}
async setup({ async setup({
clearStorage = true, clearStorage = true,
mockReleases = true mockReleases = true
@@ -335,6 +310,19 @@ export class ComfyPage {
} }
await this.goto() await this.goto()
// Unify font for consistent screenshots.
await this.page.addStyleTag({
url: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
})
await this.page.addStyleTag({
url: 'https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
})
await this.page.addStyleTag({
content: `
* {
font-family: 'Roboto Mono', 'Noto Color Emoji';
}`
})
await this.page.waitForFunction(() => document.fonts.ready) await this.page.waitForFunction(() => document.fonts.ready)
await this.page.waitForFunction( await this.page.waitForFunction(
() => () =>
@@ -579,15 +567,9 @@ export class ComfyPage {
fileName?: string fileName?: string
url?: string url?: string
dropPosition?: Position dropPosition?: Position
waitForUpload?: boolean
} = {} } = {}
) { ) {
const { const { dropPosition = { x: 100, y: 100 }, fileName, url } = options
dropPosition = { x: 100, y: 100 },
fileName,
url,
waitForUpload = false
} = options
if (!fileName && !url) if (!fileName && !url)
throw new Error('Must provide either fileName or url') throw new Error('Must provide either fileName or url')
@@ -624,14 +606,6 @@ export class ComfyPage {
// Dropping a URL (e.g., dropping image across browser tabs in Firefox) // Dropping a URL (e.g., dropping image across browser tabs in Firefox)
if (url) evaluateParams.url = url if (url) evaluateParams.url = url
// Set up response waiter for file uploads before triggering the drop
const uploadResponsePromise = waitForUpload
? this.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10000 }
)
: null
// Execute the drag and drop in the browser // Execute the drag and drop in the browser
await this.page.evaluate(async (params) => { await this.page.evaluate(async (params) => {
const dataTransfer = new DataTransfer() const dataTransfer = new DataTransfer()
@@ -698,17 +672,12 @@ export class ComfyPage {
} }
}, evaluateParams) }, evaluateParams)
// Wait for file upload to complete
if (uploadResponsePromise) {
await uploadResponsePromise
}
await this.nextFrame() await this.nextFrame()
} }
async dragAndDropFile( async dragAndDropFile(
fileName: string, fileName: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {} options: { dropPosition?: Position } = {}
) { ) {
return this.dragAndDropExternalResource({ fileName, ...options }) return this.dragAndDropExternalResource({ fileName, ...options })
} }
@@ -1289,6 +1258,9 @@ export class ComfyPage {
}, 'image/png') }, 'image/png')
}) })
}, filename) }, filename)
// Wait a bit for the download to process
await this.page.waitForTimeout(500)
} }
/** /**
@@ -1583,29 +1555,14 @@ export class ComfyPage {
return window['app'].graph.nodes return window['app'].graph.nodes
}) })
} }
async waitForGraphNodes(count: number) { async getNodeRefsByType(type: string): Promise<NodeReference[]> {
await this.page.waitForFunction((count) => {
return window['app']?.canvas.graph?.nodes?.length === count
}, count)
}
async getNodeRefsByType(
type: string,
includeSubgraph: boolean = false
): Promise<NodeReference[]> {
return Promise.all( return Promise.all(
( (
await this.page.evaluate( await this.page.evaluate((type) => {
({ type, includeSubgraph }) => { return window['app'].graph.nodes
const graph = ( .filter((n: LGraphNode) => n.type === type)
includeSubgraph ? window['app'].canvas.graph : window['app'].graph .map((n: LGraphNode) => n.id)
) as LGraph }, type)
const nodes = graph.nodes
return nodes
.filter((n: LGraphNode) => n.type === type)
.map((n: LGraphNode) => n.id)
},
{ type, includeSubgraph }
)
).map((id: NodeId) => this.getNodeRefById(id)) ).map((id: NodeId) => this.getNodeRefById(id))
) )
} }
@@ -1662,55 +1619,6 @@ export class ComfyPage {
}, focusMode) }, focusMode)
await this.nextFrame() await this.nextFrame()
} }
/**
* Get the position of a group by title.
* @param title The title of the group to find
* @returns The group's canvas position
* @throws Error if group not found
*/
async getGroupPosition(title: string): Promise<Position> {
const pos = await this.page.evaluate((title) => {
const groups = window['app'].graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
return { x: group.pos[0], y: group.pos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
/**
* Drag a group by its title.
* @param options.name The title of the group to drag
* @param options.deltaX Horizontal drag distance in screen pixels
* @param options.deltaY Vertical drag distance in screen pixels
*/
async dragGroup(options: {
name: string
deltaX: number
deltaY: number
}): Promise<void> {
const { name, deltaX, deltaY } = options
const screenPos = await this.page.evaluate((title) => {
const app = window['app']
const groups = app.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
// Position in the title area of the group
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, name)
if (!screenPos) throw new Error(`Group "${name}" not found`)
await this.dragAndDrop(screenPos, {
x: screenPos.x + deltaX,
y: screenPos.y + deltaY
})
}
} }
export const testComfySnapToGridGridSize = 50 export const testComfySnapToGridGridSize = 50

View File

@@ -159,18 +159,8 @@ export class VueNodeHelpers {
getInputNumberControls(widget: Locator) { getInputNumberControls(widget: Locator) {
return { return {
input: widget.locator('input'), input: widget.locator('input'),
decrementButton: widget.getByTestId('decrement'), incrementButton: widget.locator('button').first(),
incrementButton: widget.getByTestId('increment') decrementButton: widget.locator('button').last()
} }
} }
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId('subgraph-enter-button')
await editButton.click()
}
} }

View File

@@ -30,7 +30,7 @@ export class ComfyNodeSearchFilterSelectionPanel {
async addFilter(filterValue: string, filterType: string) { async addFilter(filterValue: string, filterType: string) {
await this.selectFilterType(filterType) await this.selectFilterType(filterType)
await this.selectFilterValue(filterValue) await this.selectFilterValue(filterValue)
await this.page.locator('button:has-text("Add")').click() await this.page.locator('.p-button-label:has-text("Add")').click()
} }
} }
@@ -60,6 +60,9 @@ export class ComfyNodeSearchBox {
await this.input.waitFor({ state: 'visible' }) await this.input.waitFor({ state: 'visible' })
await this.input.fill(nodeName) await this.input.fill(nodeName)
await this.dropdown.waitFor({ state: 'visible' }) await this.dropdown.waitFor({ state: 'visible' })
// Wait for some time for the auto complete list to update.
// The auto complete list is debounced and may take some time to update.
await this.page.waitForTimeout(500)
await this.dropdown await this.dropdown
.locator('li') .locator('li')
.nth(options?.suggestionIndex || 0) .nth(options?.suggestionIndex || 0)

View File

@@ -116,6 +116,7 @@ export class WorkflowsSidebarTab extends SidebarTab {
async switchToWorkflow(workflowName: string) { async switchToWorkflow(workflowName: string) {
const workflowLocator = this.getOpenedItem(workflowName) const workflowLocator = this.getOpenedItem(workflowName)
await workflowLocator.click() await workflowLocator.click()
await this.page.waitForTimeout(300)
} }
getOpenedItem(name: string) { getOpenedItem(name: string) {
@@ -137,13 +138,7 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click() .click()
await this.page.keyboard.type(newName) await this.page.keyboard.type(newName)
await this.page.keyboard.press('Enter') await this.page.keyboard.press('Enter')
await this.page.waitForTimeout(300)
// Wait for workflow service to finish renaming
await this.page.waitForFunction(
() => !window['app']?.extensionManager?.workflow?.isBusy,
undefined,
{ timeout: 3000 }
)
} }
async insertWorkflow(locator: Locator) { async insertWorkflow(locator: Locator) {

View File

@@ -92,26 +92,10 @@ export class Topbar {
) )
// Wait for the dialog to close. // Wait for the dialog to close.
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 }) await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
if (await confirmationDialog.isVisible()) {
return
}
} }
async openTopbarMenu() { async openTopbarMenu() {
// If menu is already open, close it first to reset state await this.page.waitForTimeout(1000)
const isAlreadyOpen = await this.menuLocator.isVisible()
if (isAlreadyOpen) {
// Click outside the menu to close it properly
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
}
await this.menuTrigger.click() await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' }) await this.menuLocator.waitFor({ state: 'visible' })
return this.menuLocator return this.menuLocator
@@ -179,36 +163,15 @@ export class Topbar {
await topLevelMenu.hover() await topLevelMenu.hover()
// Hover over top-level menu with retry logic for flaky submenu appearance
const submenu = this.getVisibleSubmenu()
try {
await submenu.waitFor({ state: 'visible', timeout: 1000 })
} catch {
// Click outside to reset, then reopen menu
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
// Re-hover on top-level menu to trigger submenu
await topLevelMenu.hover()
await submenu.waitFor({ state: 'visible', timeout: 1000 })
}
let currentMenu = topLevelMenu let currentMenu = topLevelMenu
for (let i = 1; i < path.length; i++) { for (let i = 1; i < path.length; i++) {
const commandName = path[i] const commandName = path[i]
const menuItem = submenu const menuItem = currentMenu
.locator(`.p-tieredmenu-item:has-text("${commandName}")`) .locator(
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
)
.first() .first()
await menuItem.waitFor({ state: 'visible' }) await menuItem.waitFor({ state: 'visible' })
// For the last item, click it
if (i === path.length - 1) {
await menuItem.click()
return
}
// Otherwise, hover to open nested submenu
await menuItem.hover() await menuItem.hover()
currentMenu = menuItem currentMenu = menuItem
} }

View File

@@ -79,15 +79,48 @@ export class SubgraphSlotReference {
const node = const node =
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
const slots =
type === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!node) { if (!node) {
throw new Error(`No ${type} node found in subgraph`) throw new Error(`No ${type} node found in subgraph`)
} }
// Calculate position for next available slot
// const nextSlotIndex = slots?.length || 0
// const slotHeight = 20
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
// Find last slot position
const lastSlot = slots.at(-1)
let slotX: number
let slotY: number
if (lastSlot) {
// If there are existing slots, position the new one below the last one
const gapHeight = 20
slotX = lastSlot.pos[0]
slotY = lastSlot.pos[1] + gapHeight
} else {
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
if (currentGraph.slotAnchorX !== undefined) {
// The actual slot X position seems to be slotAnchorX - 10
slotX = currentGraph.slotAnchorX - 10
} else {
// Fallback: calculate from node edge
slotX =
type === 'input'
? node.pos[0] + node.size[0] - 10 // Right edge for input node
: node.pos[0] + 10 // Left edge for output node
}
// For Y position when no slots exist, use middle of node
slotY = node.pos[1] + node.size[1] / 2
}
// Convert from offset to canvas coordinates // Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([ const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
node.emptySlot.pos[0], slotX,
node.emptySlot.pos[1] slotY
]) ])
return canvasPos return canvasPos
}, },
@@ -119,7 +152,8 @@ class NodeSlotReference {
window['app'].canvas.ds.convertOffsetToCanvas(rawPos) window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
// Debug logging - convert Float64Arrays to regular arrays for visibility // Debug logging - convert Float64Arrays to regular arrays for visibility
console.warn( // eslint-disable-next-line no-console
console.log(
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`, `NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
{ {
nodePos: [node.pos[0], node.pos[1]], nodePos: [node.pos[0], node.pos[1]],
@@ -428,6 +462,7 @@ export class NodeReference {
async convertToSubgraph() { async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph') await this.clickContextMenuOption('Convert to Subgraph')
await this.comfyPage.nextFrame() await this.comfyPage.nextFrame()
await this.comfyPage.page.waitForTimeout(256)
const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph') const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph')
if (nodes.length !== 1) { if (nodes.length !== 1) {
throw new Error( throw new Error(
@@ -476,6 +511,7 @@ export class NodeReference {
// Double-click to enter subgraph // Double-click to enter subgraph
await this.comfyPage.canvas.dblclick({ position, force: true }) await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame() await this.comfyPage.nextFrame()
await this.comfyPage.page.waitForTimeout(500)
// Check if we successfully entered the subgraph // Check if we successfully entered the subgraph
isInSubgraph = await this.comfyPage.page.evaluate(() => { isInSubgraph = await this.comfyPage.page.evaluate(() => {

View File

@@ -0,0 +1,164 @@
import type { Request, Route } from '@playwright/test'
import _ from 'es-toolkit/compat'
import fs from 'fs'
import path from 'path'
import { v4 as uuidv4 } from 'uuid'
import type {
HistoryTaskItem,
TaskItem,
TaskOutput
} from '../../../src/schemas/apiSchema'
import type { ComfyPage } from '../ComfyPage'
/** keyof TaskOutput[string] */
type OutputFileType = 'images' | 'audio' | 'animated'
const DEFAULT_IMAGE = 'example.webp'
const getFilenameParam = (request: Request) => {
const url = new URL(request.url())
return url.searchParams.get('filename') || DEFAULT_IMAGE
}
const getContentType = (filename: string, fileType: OutputFileType) => {
const subtype = path.extname(filename).slice(1)
switch (fileType) {
case 'images':
return `image/${subtype}`
case 'audio':
return `audio/${subtype}`
case 'animated':
return `video/${subtype}`
}
}
const setQueueIndex = (task: TaskItem) => {
task.prompt[0] = TaskHistory.queueIndex++
}
const setPromptId = (task: TaskItem) => {
task.prompt[1] = uuidv4()
}
export default class TaskHistory {
static queueIndex = 0
static readonly defaultTask: Readonly<HistoryTaskItem> = {
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
outputs: {},
status: {
status_str: 'success',
completed: true,
messages: []
},
taskType: 'History'
}
private tasks: HistoryTaskItem[] = []
private outputContentTypes: Map<string, string> = new Map()
constructor(readonly comfyPage: ComfyPage) {}
private loadAsset: (filename: string) => Buffer = _.memoize(
(filename: string) => {
const filePath = this.comfyPage.assetPath(filename)
return fs.readFileSync(filePath)
}
)
private async handleGetHistory(route: Route) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.tasks)
})
}
private async handleGetView(route: Route) {
const fileName = getFilenameParam(route.request())
if (!this.outputContentTypes.has(fileName)) {
return route.continue()
}
const asset = this.loadAsset(fileName)
return route.fulfill({
status: 200,
contentType: this.outputContentTypes.get(fileName),
body: asset,
headers: {
'Cache-Control': 'public, max-age=31536000',
'Content-Length': asset.byteLength.toString()
}
})
}
async setupRoutes() {
return this.comfyPage.page.route(
/.*\/api\/(view|history)(\?.*)?$/,
async (route) => {
const request = route.request()
const method = request.method()
const isViewReq = request.url().includes('view') && method === 'GET'
if (isViewReq) return this.handleGetView(route)
const isHistoryPath = request.url().includes('history')
const isGetHistoryReq = isHistoryPath && method === 'GET'
if (isGetHistoryReq) return this.handleGetHistory(route)
const isClearReq =
method === 'POST' &&
isHistoryPath &&
request.postDataJSON()?.clear === true
if (isClearReq) return this.clearTasks()
return route.continue()
}
)
}
private createOutputs(
filenames: string[],
filetype: OutputFileType
): TaskOutput {
return filenames.reduce((outputs, filename, i) => {
const nodeId = `${i + 1}`
outputs[nodeId] = {
[filetype]: [{ filename, subfolder: '', type: 'output' }]
}
const contentType = getContentType(filename, filetype)
this.outputContentTypes.set(filename, contentType)
return outputs
}, {})
}
private addTask(task: HistoryTaskItem) {
setPromptId(task)
setQueueIndex(task)
this.tasks.unshift(task) // Tasks are added to the front of the queue
}
clearTasks(): this {
this.tasks = []
return this
}
withTask(
outputFilenames: string[],
outputFiletype: OutputFileType = 'images',
overrides: Partial<HistoryTaskItem> = {}
): this {
this.addTask({
...TaskHistory.defaultTask,
outputs: this.createOutputs(outputFilenames, outputFiletype),
...overrides
})
return this
}
/** Repeats the last task in the task history a specified number of times. */
repeat(n: number): this {
for (let i = 0; i < n; i++)
this.addTask(structuredClone(this.tasks.at(0)) as HistoryTaskItem)
return this
}
}

View File

@@ -5,18 +5,14 @@ import type { AutoQueueMode } from '../../src/stores/queueStore'
export class ComfyActionbar { export class ComfyActionbar {
public readonly root: Locator public readonly root: Locator
public readonly queueButton: ComfyQueueButton public readonly queueButton: ComfyQueueButton
public readonly propertiesButton: Locator
constructor(public readonly page: Page) { constructor(public readonly page: Page) {
this.root = page.locator('.actionbar-container') this.root = page.locator('.actionbar')
this.queueButton = new ComfyQueueButton(this) this.queueButton = new ComfyQueueButton(this)
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
} }
async isDocked() { async isDocked() {
const className = await this.root const className = await this.root.getAttribute('class')
.locator('.actionbar')
.getAttribute('class')
return className?.includes('static') ?? false return className?.includes('static') ?? false
} }
} }

View File

@@ -1,5 +1,4 @@
import type { Locator, Page } from '@playwright/test' import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import path from 'path' import path from 'path'
import type { import type {
@@ -9,26 +8,16 @@ import type {
export class ComfyTemplates { export class ComfyTemplates {
readonly content: Locator readonly content: Locator
readonly allTemplateCards: Locator
constructor(readonly page: Page) { constructor(readonly page: Page) {
this.content = page.getByTestId('template-workflows-content') this.content = page.getByTestId('template-workflows-content')
this.allTemplateCards = page.locator('[data-testid^="template-workflow-"]')
}
async waitForMinimumCardCount(count: number) {
return await expect(async () => {
const cardCount = await this.allTemplateCards.count()
expect(cardCount).toBeGreaterThanOrEqual(count)
}).toPass({
timeout: 1_000
})
} }
async loadTemplate(id: string) { async loadTemplate(id: string) {
const templateCard = this.content.getByTestId(`template-workflow-${id}`) await this.content
await templateCard.scrollIntoViewIfNeeded() .getByTestId(`template-workflow-${id}`)
await templateCard.getByRole('img').click() .getByRole('img')
.click()
} }
async getAllTemplates(): Promise<TemplateInfo[]> { async getAllTemplates(): Promise<TemplateInfo[]> {

View File

@@ -75,9 +75,13 @@ test.describe('Background Image Upload', () => {
// Upload the test image // Upload the test image
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp')) await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
// Wait for upload to complete and verify the setting was updated
await comfyPage.page.waitForTimeout(500) // Give time for file reading
// Verify the URL input now has an API URL // Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.locator('input[type="text"]') const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/) const inputValue = await urlInput.inputValue()
expect(inputValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
// Verify clear button is now enabled // Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)') const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
@@ -187,11 +191,14 @@ test.describe('Background Image Upload', () => {
) )
await uploadButton.hover() await uploadButton.hover()
// Wait for tooltip to appear and verify it exists
await comfyPage.page.waitForTimeout(700) // Tooltip delay
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible') const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(uploadTooltip).toBeVisible() await expect(uploadTooltip).toBeVisible()
// Move away to hide tooltip // Move away to hide tooltip
await comfyPage.page.locator('body').hover() await comfyPage.page.locator('body').hover()
await comfyPage.page.waitForTimeout(100)
// Set a background to enable clear button // Set a background to enable clear button
const urlInput = backgroundImageSetting.locator('input[type="text"]') const urlInput = backgroundImageSetting.locator('input[type="text"]')
@@ -202,6 +209,8 @@ test.describe('Background Image Upload', () => {
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)') const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await clearButton.hover() await clearButton.hover()
// Wait for tooltip to appear and verify it exists
await comfyPage.page.waitForTimeout(700) // Tooltip delay
const clearTooltip = comfyPage.page.locator('.p-tooltip:visible') const clearTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(clearTooltip).toBeVisible() await expect(clearTooltip).toBeVisible()
}) })

View File

@@ -203,6 +203,7 @@ test.describe('Node Color Adjustments', () => {
comfyPage comfyPage
}) => { }) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5) await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.page.waitForTimeout(128)
// Drag mouse to force canvas to redraw // Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0) await comfyPage.page.mouse.move(0, 0)
@@ -210,6 +211,7 @@ test.describe('Node Color Adjustments', () => {
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png') await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0) await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.waitForTimeout(128)
await comfyPage.page.mouse.move(8, 8) await comfyPage.page.mouse.move(8, 8)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png') await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
@@ -232,25 +234,12 @@ test.describe('Node Color Adjustments', () => {
}) => { }) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5) await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.setSetting('Comfy.ColorPalette', 'light') await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame() const saveWorkflowInterval = 1000
const parsed = await ( await comfyPage.page.waitForTimeout(saveWorkflowInterval)
await comfyPage.page.waitForFunction( const workflow = await comfyPage.page.evaluate(() => {
() => { return localStorage.getItem('workflow')
const workflow = localStorage.getItem('workflow') })
if (!workflow) return null for (const node of JSON.parse(workflow ?? '{}').nodes) {
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/) if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/) if (node.color) expect(node.color).not.toMatch(/hsla/)
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

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