Merge branch 'main' into sno-storybook--settings-panel

This commit is contained in:
snomiao
2025-09-30 02:25:37 +00:00
769 changed files with 56390 additions and 31769 deletions

View File

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

4
.gitattributes vendored
View File

@@ -12,5 +12,5 @@
*.yaml text eol=lf
# Generated files
src/types/comfyRegistryTypes.ts linguist-generated=true
src/types/generatedManagerTypes.ts linguist-generated=true
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true

View File

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

26
.github/workflows/devtools-python.yaml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Devtools Python Check
on:
pull_request:
paths:
- 'tools/devtools/**'
push:
branches: [ main ]
paths:
- 'tools/devtools/**'
jobs:
syntax:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Validate Python syntax
run: python3 -m compileall -q tools/devtools

View File

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

View File

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

View File

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

15
.github/workflows/json-validate.yaml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Validate JSON
on:
push:
branches:
- main
pull_request:
jobs:
json-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate JSON syntax
run: ./scripts/cicd/check-json.sh

View File

@@ -88,6 +88,8 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Build types
run: pnpm build:types
@@ -131,7 +133,7 @@ jobs:
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
run: pnpm publish --access public --tag "${{ inputs.dist_tag }}"
run: pnpm publish --access public --tag "${{ inputs.dist_tag }}" --no-git-checks
working-directory: dist
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

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

View File

@@ -27,12 +27,10 @@ jobs:
repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend'
- name: Checkout ComfyUI_devtools
uses: actions/checkout@v4
with:
repository: 'Comfy-Org/ComfyUI_devtools'
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
- name: Copy ComfyUI_devtools from frontend repo
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -150,7 +148,7 @@ jobs:
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: npx playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
working-directory: ComfyUI_frontend
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ../blob-report
@@ -232,7 +230,7 @@ jobs:
run: |
# Run tests with both HTML and JSON reporters
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
npx playwright test --project=${{ matrix.browser }} \
pnpm exec playwright test --project=${{ matrix.browser }} \
--reporter=list \
--reporter=html \
--reporter=json
@@ -283,10 +281,10 @@ jobs:
- name: Merge into HTML Report
run: |
# Generate HTML report
npx playwright 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
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
npx playwright merge-reports --reporter=json ./all-blob-reports
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
working-directory: ComfyUI_frontend
- name: Upload HTML report

View File

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

View File

@@ -68,17 +68,18 @@ jobs:
- name: Generate API types
run: |
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
npx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts
mkdir -p ./packages/registry-types/src
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Validate generated types
run: |
if [ ! -f ./src/types/comfyRegistryTypes.ts ]; then
if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
echo "Error: Types file was not generated."
exit 1
fi
# Check if file is not empty
if [ ! -s ./src/types/comfyRegistryTypes.ts ]; then
if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
echo "Error: Generated types file is empty."
exit 1
fi
@@ -86,12 +87,12 @@ jobs:
- name: Lint generated types
run: |
echo "Linting generated Comfy Registry API types..."
pnpm lint:fix:no-cache -- ./src/types/comfyRegistryTypes.ts
pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Check for changes
id: check-changes
run: |
if [[ -z $(git status --porcelain ./src/types/comfyRegistryTypes.ts) ]]; then
if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then
echo "No changes to Comfy Registry API types detected."
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
@@ -121,4 +122,4 @@ jobs:
labels: CNR
delete-branch: true
add-paths: |
src/types/comfyRegistryTypes.ts
packages/registry-types/src/comfyRegistryTypes.ts

7
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,10 +31,9 @@
- Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config.
## Commit & Pull Request Guidelines
- Commits: Prefer Conventional Commits (e.g., `feat(ui): add sidebar`), `refactor(litegraph): …`. Use `[skip ci]` for locale-only updates when appropriate.
- PRs: Include clear description, linked issues (`Fixes #123`), and screenshots/GIFs for UI changes. Add/adjust tests and i18n strings when applicable.
- 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.
## Security & Configuration Tips
- Secrets: Use `.env` (see `.env_example`); do not commit secrets.
- Backend: Dev server expects ComfyUI backend at `localhost:8188` by default; configure via `.env`.

View File

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

View File

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

View File

@@ -16,15 +16,20 @@ Without this flag, parallel tests will conflict and fail randomly.
### ComfyUI devtools
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
ComfyUI_devtools is now included in this repository under `tools/devtools/`. During CI/CD, these files are automatically copied to the `custom_nodes` directory.
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
For local development, copy the devtools files to your ComfyUI installation:
```bash
cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
```
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
```bash
npx playwright install chromium --with-deps
pnpm exec playwright install chromium --with-deps
```
### Environment Configuration
@@ -51,14 +56,6 @@ TEST_COMFYUI_DIR=/path/to/your/ComfyUI
### Common Setup Issues
**Most tests require the new menu system** - Add to your test:
```typescript
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
```
### Release API Mocking
By default, all tests mock the release API (`api.comfy.org/releases`) to prevent release notification popups from interfering with test execution. This is necessary because the release notifications can appear over UI elements and block test interactions.
@@ -76,7 +73,7 @@ For tests that specifically need to test release functionality, see the example
**Always use UI mode for development:**
```bash
npx playwright test --ui
pnpm exec playwright test --ui
```
UI mode features:
@@ -92,8 +89,8 @@ UI mode features:
For CI or headless testing:
```bash
npx playwright test # Run all tests
npx playwright test widget.spec.ts # Run specific test file
pnpm exec playwright test # Run all tests
pnpm exec playwright test widget.spec.ts # Run specific test file
```
### Local Development Config
@@ -389,7 +386,7 @@ export default defineConfig({
Option 2 - Generate local baselines for comparison:
```bash
npx playwright test --update-snapshots
pnpm exec playwright test --update-snapshots
```
### Creating New Screenshot Baselines

View File

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

View File

@@ -0,0 +1,221 @@
{
"id": "e74f5af9-b886-4a21-abbf-ed535d12e2fb",
"revision": 0,
"last_node_id": 8,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadAudio",
"pos": [
41.52964782714844,
16.930862426757812
],
"size": [
444,
125
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "AUDIO",
"type": "AUDIO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadAudio"
},
"widgets_values": [
null,
null,
""
]
},
{
"id": 2,
"type": "LoadVideo",
"pos": [
502.28570556640625,
16.857147216796875
],
"size": [
444,
525
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VIDEO",
"type": "VIDEO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadVideo"
},
"widgets_values": [
null,
"image"
]
},
{
"id": 3,
"type": "DevToolsLoadAnimatedImageTest",
"pos": [
41.71427917480469,
188.0000457763672
],
"size": [
444,
553
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsLoadAnimatedImageTest"
},
"widgets_values": [
null,
"image"
]
},
{
"id": 5,
"type": "LoadImage",
"pos": [
958.285888671875,
16.57145118713379
],
"size": [
444,
553
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
null,
"image"
]
},
{
"id": 6,
"type": "LoadImageMask",
"pos": [
503.4285888671875,
588
],
"size": [
444,
563
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImageMask"
},
"widgets_values": [
null,
"alpha",
"image"
]
},
{
"id": 7,
"type": "LoadImageOutput",
"pos": [
965.1429443359375,
612
],
"size": [
444,
553
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImageOutput"
},
"widgets_values": [
null,
false,
"refresh",
"image"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
},
"frontendVersion": "1.28.3"
},
"version": 0.4
}

View File

@@ -1643,7 +1643,7 @@ export const comfyPageFixture = base.extend<{
try {
await comfyPage.setupSettings({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.UseNewMenu': 'Top',
// Hide canvas menu/info/selection toolbox by default.
'Comfy.Graph.CanvasInfo': false,
'Comfy.Graph.CanvasMenu': false,

View File

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

View File

@@ -22,6 +22,13 @@ export class VueNodeHelpers {
)
}
/**
* Get locator for a Vue node by the node's title (displayed name in the header)
*/
getNodeByTitle(title: string): Locator {
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
}
/**
* Get total count of Vue nodes in the DOM
*/

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { Locator, Page, expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
export class Topbar {
private readonly menuLocator: Locator

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import path from 'path'
import {
import type {
TemplateInfo,
WorkflowTemplates
} from '../../src/platform/workflow/templates/types/template'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,13 @@
import { Locator, expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Load workflow warning', () => {
test('Should display a warning when loading a workflow with missing nodes', async ({
comfyPage

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

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

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { SettingParams } from '../../src/platform/settings/types'
import type { SettingParams } from '../../src/platform/settings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Topbar commands', () => {

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Remote COMBO Widget', () => {
const mockOptions = ['d', 'c', 'b', 'a']

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

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

View File

@@ -4,6 +4,9 @@ import { comfyPageFixture } from '../fixtures/ComfyPage'
const test = comfyPageFixture
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Selection Toolbox - More Options Submenus', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -2,6 +2,10 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Settings Search functionality', () => {
test.beforeEach(async ({ comfyPage }) => {
// Register test settings to verify hidden/deprecated filtering

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { SystemStats } from '../../src/schemas/apiSchema'
import type { SystemStats } from '../../src/schemas/apiSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Version Mismatch Warnings', () => {
@@ -85,6 +85,7 @@ test.describe('Version Mismatch Warnings', () => {
test('should persist dismissed state across sessions', async ({
comfyPage
}) => {
test.setTimeout(30_000)
// Mock system_stats route to indicate that the installed version is always ahead of the required version
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({
@@ -106,6 +107,11 @@ test.describe('Version Mismatch Warnings', () => {
const dismissButton = warningToast.getByRole('button', { name: 'Close' })
await dismissButton.click()
// Wait for the dismissed state to be persisted
await comfyPage.page.waitForFunction(
() => !!localStorage.getItem('comfy.versionMismatch.dismissals')
)
// Reload the page, keeping local storage
await comfyPage.setup({ clearStorage: false })

View File

@@ -0,0 +1,33 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const CREATE_GROUP_HOTKEY = 'Control+g'
test.describe('Vue Node Groups', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should allow creating groups with hotkey', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-groups-create-group.png'
)
})
test('should allow fitting group to contents', async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.loadWorkflow('groups/oversized_group')
await comfyPage.ctrlA()
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-groups-fit-to-contents.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -0,0 +1,18 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Canvas Pan', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('@mobile Can pan with touch', async ({ comfyPage }) => {
await comfyPage.panWithTouch({ x: 64, y: 64 }, { x: 256, y: 256 })
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-paned-with-touch.png'
)
})
})

View File

@@ -0,0 +1,33 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Zoom', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should not capture drag while zooming with ctrl+shift+drag', async ({
comfyPage
}) => {
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBoundingBox = await checkpointNode.boundingBox()
if (!nodeBoundingBox) throw new Error('Node bounding box not available')
const nodeMidpointX = nodeBoundingBox.x + nodeBoundingBox.width / 2
const nodeMidpointY = nodeBoundingBox.y + nodeBoundingBox.height / 2
// Start the Ctrl+Shift drag-to-zoom on the canvas and continue dragging over
// the node. The node should not capture the drag while drag-zooming.
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(
{ x: 200, y: 300 },
{ x: nodeMidpointX, y: nodeMidpointY }
)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,696 @@
import type { Locator, Page } from '@playwright/test'
import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema'
import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { getMiddlePoint } from '../../../../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '../../../../helpers/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()
if (!box) throw new Error('Slot bounding box not available')
return {
x: box.x + box.width / 2,
y: box.y + box.height / 2
}
}
async function getInputLinkDetails(
page: Page,
nodeId: NodeId,
slotIndex: number
) {
return await page.evaluate(
([targetNodeId, targetSlot]) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return null
const node = graph.getNodeById(targetNodeId)
if (!node) return null
const input = node.inputs?.[targetSlot]
if (!input) return null
const linkId = input.link
if (linkId == null) return null
const link = graph.getLink?.(linkId)
if (!link) return null
return {
id: link.id,
originId: link.origin_id,
originSlot:
typeof link.origin_slot === 'string'
? Number.parseInt(link.origin_slot, 10)
: link.origin_slot,
targetId: link.target_id,
targetSlot:
typeof link.target_slot === 'string'
? Number.parseInt(link.target_slot, 10)
: link.target_slot,
parentId: link.parentId ?? null
}
},
[nodeId, slotIndex] as const
)
}
// Test helpers to reduce repetition across cases
function slotLocator(
page: Page,
nodeId: NodeId,
slotIndex: number,
isInput: boolean
) {
const key = getSlotKey(String(nodeId), slotIndex, isInput)
return page.locator(`[data-slot-key="${key}"]`)
}
async function expectVisibleAll(...locators: Locator[]) {
await Promise.all(locators.map((l) => expect(l).toBeVisible()))
}
async function getSlotCenter(
page: Page,
nodeId: NodeId,
slotIndex: number,
isInput: boolean
) {
const locator = slotLocator(page, nodeId, slotIndex, isInput)
await expect(locator).toBeVisible()
return await getCenter(locator)
}
async function connectSlots(
page: Page,
from: { nodeId: NodeId; index: number },
to: { nodeId: NodeId; index: number },
nextFrame: () => Promise<void>
) {
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
const toLoc = slotLocator(page, to.nodeId, to.index, true)
await expectVisibleAll(fromLoc, toLoc)
await fromLoc.dragTo(toLoc)
await nextFrame()
}
test.describe('Vue Node Link Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
})
test('should show a link dragging out from a slot when dragging on a slot', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const slot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
await expect(slot).toBeVisible()
const start = await getCenter(slot)
// Arbitrary value
const dragTarget = {
x: start.x + 180,
y: start.y - 140
}
await comfyMouse.move(start)
await comfyMouse.drag(dragTarget)
await comfyPage.nextFrame()
try {
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-dragging-link.png'
)
} finally {
await comfyMouse.drop()
}
})
test('should create a link when dropping on a compatible slot', async ({
comfyPage
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1)
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails).toMatchObject({
originId: samplerNode.id,
originSlot: 0,
targetId: vaeNode.id,
targetSlot: 0
})
})
test('should not create a link when slot types are incompatible', async ({
comfyPage
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
expect(samplerNode && clipNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const clipInput = await clipNode.getInput(0)
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
await expectVisibleAll(outputSlot, inputSlot)
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await clipInput.getLinkCount()).toBe(0)
const graphLinkDetails = await getInputLinkDetails(
comfyPage.page,
clipNode.id,
0
)
expect(graphLinkDetails).toBeNull()
})
test('should not create a link when dropping onto a slot on the same node', async ({
comfyPage
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const samplerInput = await samplerNode.getInput(3)
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
await expectVisibleAll(outputSlot, inputSlot)
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await samplerInput.getLinkCount()).toBe(0)
})
test('should reuse the existing origin when dragging an input link', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
await comfyMouse.drag(vaeInputCenter)
await comfyMouse.drop()
const dragTarget = {
x: vaeInputCenter.x + 160,
y: vaeInputCenter.y - 100
}
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-input-drag-reuses-origin.png'
)
await comfyMouse.drop()
})
test('ctrl+alt drag from an input starts a fresh link', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
await comfyMouse.drag(vaeInputCenter)
await comfyMouse.drop()
await comfyPage.nextFrame()
const dragTarget = {
x: vaeInputCenter.x + 140,
y: vaeInputCenter.y - 110
}
await comfyMouse.move(vaeInputCenter)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Alt')
try {
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-input-drag-ctrl-alt.png'
)
} finally {
await comfyMouse.drop().catch(() => {})
await comfyPage.page.keyboard.up('Alt').catch(() => {})
await comfyPage.page.keyboard.up('Control').catch(() => {})
}
await comfyPage.nextFrame()
// Tcehnically intended to disconnect existing as well
expect(await vaeInput.getLinkCount()).toBe(0)
expect(await samplerOutput.getLinkCount()).toBe(0)
})
test('dropping an input link back on its slot restores the original connection', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
try {
await comfyMouse.drag(vaeInputCenter)
} finally {
await comfyMouse.drop()
}
await comfyPage.nextFrame()
const originalLink = await getInputLinkDetails(
comfyPage.page,
vaeNode.id,
0
)
expect(originalLink).not.toBeNull()
const dragTarget = {
x: vaeInputCenter.x + 150,
y: vaeInputCenter.y - 100
}
// To prevent needing a screenshot expectation for whether the link's off
const vaeInputLocator = slotLocator(comfyPage.page, vaeNode.id, 0, true)
const inputBox = await vaeInputLocator.boundingBox()
if (!inputBox) throw new Error('Input slot bounding box not available')
const isOutsideX =
dragTarget.x < inputBox.x || dragTarget.x > inputBox.x + inputBox.width
const isOutsideY =
dragTarget.y < inputBox.y || dragTarget.y > inputBox.y + inputBox.height
expect(isOutsideX || isOutsideY).toBe(true)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drop()
await comfyPage.nextFrame()
const restoredLink = await getInputLinkDetails(
comfyPage.page,
vaeNode.id,
0
)
expect(restoredLink).not.toBeNull()
if (!restoredLink || !originalLink) {
throw new Error('Expected both original and restored links to exist')
}
expect(restoredLink).toMatchObject({
originId: originalLink.originId,
originSlot: originalLink.originSlot,
targetId: originalLink.targetId,
targetSlot: originalLink.targetSlot,
parentId: originalLink.parentId
})
expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1)
})
test('rerouted input drag preview remains anchored to reroute', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
const outputPosition = await samplerOutput.getPosition()
const inputPosition = await vaeInput.getPosition()
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
if (!node) throw new Error('Target node not found')
const input = node.inputs?.[targetSlot]
if (!input) throw new Error('Target input slot not found')
const linkId = input.link
if (linkId == null) throw new Error('Expected existing link on input')
const link = graph.getLink(linkId)
if (!link) throw new Error('Link not found')
// Convert the client/canvas pixel coordinates to graph space
const pos = app.canvas.ds.convertCanvasToOffset([
clientPoint.x,
clientPoint.y
])
graph.createReroute(pos, link)
},
[vaeNode.id, 0, reroutePoint] as const
)
await comfyPage.nextFrame()
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
const dragTarget = {
x: vaeInputCenter.x + 160,
y: vaeInputCenter.y - 120
}
let dropped = false
try {
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-reroute-input-drag.png'
)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drop()
dropped = true
} finally {
if (!dropped) {
await comfyMouse.drop().catch(() => {})
}
}
await comfyPage.nextFrame()
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails?.originId).toBe(samplerNode.id)
expect(linkDetails?.parentId).not.toBeNull()
})
test('rerouted output shift-drag preview remains anchored to reroute', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
const outputPosition = await samplerOutput.getPosition()
const inputPosition = await vaeInput.getPosition()
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
if (!node) throw new Error('Target node not found')
const input = node.inputs?.[targetSlot]
if (!input) throw new Error('Target input slot not found')
const linkId = input.link
if (linkId == null) throw new Error('Expected existing link on input')
const link = graph.getLink(linkId)
if (!link) throw new Error('Link not found')
// Convert the client/canvas pixel coordinates to graph space
const pos = app.canvas.ds.convertCanvasToOffset([
clientPoint.x,
clientPoint.y
])
graph.createReroute(pos, link)
},
[vaeNode.id, 0, reroutePoint] as const
)
await comfyPage.nextFrame()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dragTarget = {
x: outputCenter.x + 150,
y: outputCenter.y - 140
}
let dropPending = false
let shiftHeld = false
try {
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
shiftHeld = true
dropPending = true
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-reroute-output-shift-drag.png'
)
await comfyMouse.move(outputCenter)
await comfyMouse.drop()
dropPending = false
} finally {
if (dropPending) await comfyMouse.drop().catch(() => {})
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
await comfyPage.nextFrame()
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails?.originId).toBe(samplerNode.id)
expect(linkDetails?.parentId).not.toBeNull()
})
test('dragging input to input drags existing link', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
// Step 1: Connect CLIP's only output (index 0) to KSampler's second input (index 1)
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 1 },
() => comfyPage.nextFrame()
)
// Verify initial link exists between CLIP -> KSampler input[1]
const initialLink = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
1
)
expect(initialLink).not.toBeNull()
expect(initialLink).toMatchObject({
originId: clipNode.id,
targetId: samplerNode.id,
targetSlot: 1
})
// Step 2: Drag from KSampler's second input to its third input (index 2)
const input2Center = await getSlotCenter(
comfyPage.page,
samplerNode.id,
1,
true
)
const input3Center = await getSlotCenter(
comfyPage.page,
samplerNode.id,
2,
true
)
await comfyMouse.move(input2Center)
await comfyMouse.drag(input3Center)
await comfyMouse.drop()
await comfyPage.nextFrame()
// Expect old link removed from input[1]
const afterSecondInput = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
1
)
expect(afterSecondInput).toBeNull()
// Expect new link exists at input[2] from CLIP
const afterThirdInput = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
2
)
expect(afterThirdInput).not.toBeNull()
expect(afterThirdInput).toMatchObject({
originId: clipNode.id,
targetId: samplerNode.id,
targetSlot: 2
})
})
test('shift-dragging an output with multiple links should drag all links', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
const clipOutput = await clipNode.getOutput(0)
// Connect output[0] -> inputs[1] and [2]
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 1 },
() => comfyPage.nextFrame()
)
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 2 },
() => comfyPage.nextFrame()
)
expect(await clipOutput.getLinkCount()).toBe(2)
const outputCenter = await getSlotCenter(
comfyPage.page,
clipNode.id,
0,
false
)
const dragTarget = {
x: outputCenter.x + 40,
y: outputCenter.y - 140
}
let dropPending = false
let shiftHeld = false
try {
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
shiftHeld = true
await comfyMouse.drag(dragTarget)
dropPending = true
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-shift-output-multi-link.png'
)
} finally {
if (dropPending) await comfyMouse.drop().catch(() => {})
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
})
})

View File

@@ -1,6 +1,10 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Vue Nodes - Delete Key Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -0,0 +1,76 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Nodes Renaming', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('should display node title', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
expect(nodes.length).toBeGreaterThanOrEqual(1)
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const title = await vueNode.getTitle()
expect(title).toBe('KSampler')
// Verify title is visible in the header
const header = await vueNode.getHeader()
await expect(header).toContainText('KSampler')
})
test('should allow title renaming by double clicking on the node header', async ({
comfyPage
}) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Test renaming with Enter
await vueNode.setTitle('My Custom Sampler')
const newTitle = await vueNode.getTitle()
expect(newTitle).toBe('My Custom Sampler')
// Verify the title is displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('My Custom Sampler')
// Test cancel with Escape
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await comfyPage.nextFrame()
// Type a different value but cancel
const input = (await vueNode.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill('This Should Be Cancelled')
await input.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
const titleAfterCancel = await vueNode.getTitle()
expect(titleAfterCancel).toBe('My Custom Sampler')
})
test('Double click node body does not trigger edit', async ({
comfyPage
}) => {
const loadCheckpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBbox = await loadCheckpointNode.boundingBox()
if (!nodeBbox) throw new Error('Node not found')
await loadCheckpointNode.dblclick()
const editingTitleInput = comfyPage.page.getByTestId('node-title-input')
await expect(editingTitleInput).not.toBeVisible()
})
})

View File

@@ -0,0 +1,52 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Vue Node Selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
const modifiers = [
{ key: 'Control', name: 'ctrl' },
{ key: 'Shift', name: 'shift' },
{ key: 'Meta', name: 'meta' }
] as const
for (const { key: modifier, name } of modifiers) {
test(`should allow selecting multiple nodes with ${name}+click`, async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Empty Latent Image').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
await comfyPage.page.getByText('KSampler').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
})
test(`should allow de-selecting nodes with ${name}+click`, async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Load Checkpoint').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

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