Compare commits

..

2 Commits

Author SHA1 Message Date
bymyself
4e5c939226 add to git blame ignore 2025-09-07 15:12:03 -07:00
bymyself
7650c55b4f refactor rendering-related files to DDD organization 2025-09-07 14:48:28 -07:00
758 changed files with 6472 additions and 27293 deletions

View File

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

View File

@@ -36,7 +36,7 @@ jobs:
if [ -z "$EXISTING_BACKPORTS" ]; then
echo "skip=false" >> $GITHUB_OUTPUT
exit 0
return
fi
echo "Found existing backport PRs:"
@@ -133,10 +133,11 @@ 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 }}
run: |
PR_TITLE="${{ github.event.pull_request.title }}"
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
for backport in ${{ steps.backport.outputs.success }}; do
IFS=':' read -r version branch <<< "${backport}"

View File

@@ -47,7 +47,6 @@ jobs:
needs: wait-for-ci
if: needs.wait-for-ci.outputs.should-proceed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -70,17 +69,19 @@ jobs:
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@v1.0.6
uses: anthropics/claude-code-action@main
with:
label_trigger: "claude-review"
prompt: |
direct_prompt: |
Read the file .claude/commands/comprehensive-pr-review.md and follow ALL the instructions exactly.
CRITICAL: You must post individual inline comments using the gh api commands shown in the file.
DO NOT create a summary comment.
Each issue must be posted as a separate inline comment on the specific line of code.
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: "--max-turns 256 --allowedTools 'Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch'"
max_turns: 256
timeout_minutes: 30
allowed_tools: "Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch"
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

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

View File

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

View File

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

View File

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

5
.gitignore vendored
View File

@@ -51,7 +51,6 @@ tests-ui/workflows/examples
/blob-report/
/playwright/.cache/
browser_tests/**/*-win32.png
browser_tests/local/
.env
@@ -78,8 +77,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
npx lint-staged
npx tsx scripts/check-unused-i18n-keys.ts

View File

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

1
.npmrc
View File

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

View File

@@ -15,32 +15,21 @@ 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
// 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'))
config.plugins = config.plugins.filter((plugin: any) => {
if (plugin && plugin.name && plugin.name.includes('import-map')) {
return false
}
return true
})
}
return mergeConfig(config, {
// Replace plugins entirely to avoid inheritance issues
plugins: [
// Only include plugins we explicitly need for Storybook
tailwindcss(),
Icons({
compiler: 'vue3',
customCollections: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,9 @@ 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(
...rest: ConstructorParameters<typeof window.WebSocket>
) {
super(...rest)
constructor() {
// @ts-expect-error
super(...arguments)
store[this.url] = this
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
ComfyPage,
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'

View File

@@ -1,5 +1,4 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { Page, expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'

View File

@@ -1,5 +1,4 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import { Locator, expect } from '@playwright/test'
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
@@ -37,10 +36,6 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('missing/missing_nodes')
await comfyPage.closeDialog()
// Wait for any async operations to complete after dialog closes
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(100)
// Make a change to the graph
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

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

View File

@@ -1,7 +1,6 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Group Node', () => {

View File

@@ -1,13 +1,12 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Position } from '@vueuse/core'
import { Locator, expect } from '@playwright/test'
import { 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.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -1013,8 +1012,6 @@ 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: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -149,7 +149,7 @@ test.describe('Selection Toolbox', () => {
// Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes
const selectedNode = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
expect(await selectedNode.getProperty('color')).not.toBeNull()
expect(selectedNode.getProperty('color')).not.toBeNull()
})
test('color picker shows current color of selected nodes', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

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

View File

@@ -160,9 +160,7 @@ test.describe.skip('Queue sidebar', () => {
comfyPage
}) => {
await comfyPage.nextFrame()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
})
test('maintains active gallery item when new tasks are added', async ({
@@ -176,9 +174,7 @@ 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
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
})
test.describe('Gallery navigation', () => {
@@ -200,9 +196,7 @@ test.describe.skip('Queue sidebar', () => {
delay: 256
})
await comfyPage.nextFrame()
await expect(
comfyPage.menu.queueTab.getGalleryImage(end)
).toBeVisible()
expect(comfyPage.menu.queueTab.getGalleryImage(end)).toBeVisible()
})
})
})

View File

@@ -1,5 +1,4 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { Page, expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import type { SystemStats } from '../../src/schemas/apiSchema'
import { SystemStats } from '../../src/schemas/apiSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Version Mismatch Warnings', () => {

View File

@@ -1,141 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Vue Nodes - Delete Key Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
// Enable Vue nodes rendering
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setup()
})
test('Can select all and delete Vue nodes with Delete key', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
// Get initial Vue node count
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
// Select all Vue nodes
await comfyPage.ctrlA()
// Verify all Vue nodes are selected
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(initialNodeCount)
// Delete with Delete key
await comfyPage.vueNodes.deleteSelected()
// Verify all Vue nodes were deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(0)
})
test('Can select specific Vue node and delete it', async ({ comfyPage }) => {
await comfyPage.vueNodes.waitForNodes()
// Get initial Vue node count
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
// Get first Vue node ID and select it
const nodeIds = await comfyPage.vueNodes.getNodeIds()
await comfyPage.vueNodes.selectNode(nodeIds[0])
// Verify selection
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(1)
// Delete with Delete key
await comfyPage.vueNodes.deleteSelected()
// Verify one Vue node was deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
})
test('Can select and delete Vue node with Backspace key', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
// Select first Vue node
const nodeIds = await comfyPage.vueNodes.getNodeIds()
await comfyPage.vueNodes.selectNode(nodeIds[0])
// Delete with Backspace key instead of Delete
await comfyPage.vueNodes.deleteSelectedWithBackspace()
// Verify Vue node was deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
})
test('Delete key does not delete node when typing in Vue node widgets', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
// Find a text input widget in a Vue node
const textWidget = comfyPage.page
.locator('input[type="text"], textarea')
.first()
// Click on text widget to focus it
await textWidget.click()
await textWidget.fill('test text')
// Press Delete while focused on widget - should delete text, not node
await textWidget.press('Delete')
// Node count should remain the same
const finalNodeCount = await comfyPage.getGraphNodesCount()
expect(finalNodeCount).toBe(initialNodeCount)
})
test('Delete key does not delete node when nothing is selected', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
// Ensure no Vue nodes are selected
await comfyPage.vueNodes.clearSelection()
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(0)
// Press Delete key - should not crash and should handle gracefully
await comfyPage.page.keyboard.press('Delete')
// Vue node count should remain the same
const nodeCount = await comfyPage.vueNodes.getNodeCount()
expect(nodeCount).toBeGreaterThan(0)
})
test('Can multi-select with Ctrl+click and delete multiple Vue nodes', async ({
comfyPage
}) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
// Multi-select first two Vue nodes using Ctrl+click
const nodeIds = await comfyPage.vueNodes.getNodeIds()
const nodesToSelect = nodeIds.slice(0, 2)
await comfyPage.vueNodes.selectNodes(nodesToSelect)
// Verify expected nodes are selected
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(nodesToSelect.length)
// Delete selected Vue nodes
await comfyPage.vueNodes.deleteSelected()
// Verify expected nodes were deleted
const finalNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - nodesToSelect.length)
})
})

View File

@@ -264,13 +264,7 @@ test.describe('Animated image widget', () => {
expect(filename).toContain('animated_webp.webp')
})
// FIXME: This test keeps flip-flopping because it relies on animated webp timing,
// which is inherently unreliable in CI environments. The test asset is an animated
// webp with 2 frames, and the test depends on animation frame timing to verify that
// animated webp images are properly displayed (as opposed to being treated as static webp).
// While the underlying functionality works (animated webp are correctly distinguished
// from static webp), the test is flaky due to timing dependencies with webp animation frames.
test.fixme('Can preview saved animated webp image', async ({ comfyPage }) => {
test('Can preview saved animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/save_animated_webp')
// Get position of the load animated webp node

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

@@ -1,20 +0,0 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/assets/css/style.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@@ -1,62 +0,0 @@
# 4. Fork PrimeVue UI Library
Date: 2025-08-27
## Status
Rejected
## Context
ComfyUI's frontend requires modifications to PrimeVue components that cannot be achieved through the library's customization APIs. Two specific technical incompatibilities have been identified with the transform-based canvas architecture:
**Screen Coordinate Hit-Testing Conflicts:**
- PrimeVue components use `getBoundingClientRect()` for screen coordinate calculations that don't account for CSS transforms
- The Slider component directly uses raw `pageX/pageY` coordinates ([lines 102-103](https://github.com/primefaces/primevue/blob/master/packages/primevue/src/slider/Slider.vue#L102-L103)) without transform-aware positioning
- This breaks interaction in transformed coordinate spaces where screen coordinates don't match logical element positions
**Virtual Canvas Scroll Interference:**
- LiteGraph's infinite canvas uses scroll coordinates semantically for graph navigation via the `DragAndScale` coordinate system
- PrimeVue overlay components automatically trigger `scrollIntoView` behavior which interferes with this virtual positioning
- This issue is documented in [PrimeVue discussion #4270](https://github.com/orgs/primefaces/discussions/4270) where the feature request was made to disable this behavior
**Historical Overlay Issues:**
- Previous z-index positioning conflicts required manual workarounds (commit `6d4eafb0`) where PrimeVue Dialog components needed `autoZIndex: false` and custom mask styling, later resolved by removing PrimeVue's automatic z-index management entirely
**Minimal Update Overhead:**
- Analysis of git history shows only 2 PrimeVue version updates in 2+ years, indicating that upstream sync overhead is negligible for this project
**Future Interaction System Requirements:**
- The ongoing canvas architecture evolution will require more granular control over component interaction and event handling as the transform-based system matures
- Predictable need for additional component modifications beyond current identified issues
## Decision
We will **NOT** fork PrimeVue. After evaluation, forking was determined to be unnecessarily complex and costly.
**Rationale for Rejection:**
- **Significant Implementation Complexity**: PrimeVue is structured as a monorepo ([primefaces/primevue](https://github.com/primefaces/primevue)) with significant code in a separate monorepo ([PrimeUIX](https://github.com/primefaces/primeuix)). Forking would require importing both repositories whole and selectively pruning or exempting components from our workspace tooling, adding substantial complexity.
- **Alternative Solutions Available**: The modifications we identified (e.g., scroll interference issues, coordinate system conflicts) have less costly solutions that don't require maintaining a full fork. For example, coordinate issues could be addressed through event interception and synthetic event creation with scaled values.
- **Maintenance Burden**: Ongoing maintenance and upgrades would be very painful, requiring manual conflict resolution and keeping pace with upstream changes across multiple repositories.
- **Limited Tooling Support**: There isn't adequate tooling that provides the granularity needed to cleanly manage a PrimeVue fork within our existing infrastructure.
## Consequences
### Alternative Approach
- **Use PrimeVue as External Dependency**: Continue using PrimeVue as a standard npm dependency
- **Targeted Workarounds**: Implement specific solutions for identified issues (coordinate system conflicts, scroll interference) without forking the entire library
- **Selective Component Replacement**: Use libraries like shadcn/ui to replace specific problematic PrimeVue components and adjust them to match our design system
- **Upstream Engagement**: Continue engaging with PrimeVue community for feature requests and bug reports
- **Maintain Flexibility**: Preserve ability to upgrade PrimeVue versions without fork maintenance overhead
## Notes
- Technical issues documented in the Context section remain valid concerns
- Solutions will be pursued through targeted fixes rather than wholesale forking
- Future re-evaluation possible if PrimeVue's architecture significantly changes or if alternative tooling becomes available
- This decision prioritizes maintainability and development velocity over maximum customization control

View File

@@ -13,7 +13,6 @@ An Architecture Decision Record captures an important architectural decision mad
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
## Creating a New ADR

102
eslint.config.js Normal file
View File

@@ -0,0 +1,102 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import pluginJs from '@eslint/js'
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import storybook from 'eslint-plugin-storybook'
import unusedImports from 'eslint-plugin-unused-imports'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default [
{
files: ['src/**/*.{js,mjs,cjs,ts,vue}']
},
{
ignores: [
'src/scripts/*',
'src/extensions/core/*',
'src/types/vue-shim.d.ts',
'src/types/comfyRegistryTypes.ts',
'src/types/generatedManagerTypes.ts',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*'
]
},
{
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
},
parser: tseslint.parser,
parserOptions: {
project: ['./tsconfig.json', './tsconfig.eslint.json'],
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions: ['.vue']
}
}
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
{
files: ['src/**/*.vue'],
languageOptions: {
parserOptions: {
parser: tseslint.parser
}
}
},
{
plugins: {
'unused-imports': unusedImports,
'@intlify/vue-i18n': pluginI18n
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'unused-imports/no-unused-imports': 'error',
'vue/no-v-html': 'off',
// i18n rules
'@intlify/vue-i18n/no-raw-text': [
'error',
{
// Ignore strings that are:
// 1. Less than 2 characters
// 2. Only symbols/numbers/whitespace (no letters)
// 3. Match specific patterns
ignorePattern:
'^[^a-zA-Z]*$|^.{0,1}$|^[\\w._%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$',
ignoreNodes: ['md-icon', 'v-icon', 'pre', 'code', 'script', 'style'],
// Brand names and technical terms that shouldn't be translated
ignoreText: [
'ComfyUI',
'GitHub',
'OpenAI',
'API',
'URL',
'JSON',
'YAML',
'GPU',
'CPU',
'RAM',
'GB',
'MB',
'KB',
'ms',
'fps',
'px',
'App Data:',
'App Path:'
]
}
]
}
},
...storybook.configs['flat/recommended']
]

View File

@@ -1,166 +0,0 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import pluginJs from '@eslint/js'
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import storybook from 'eslint-plugin-storybook'
import unusedImports from 'eslint-plugin-unused-imports'
import pluginVue from 'eslint-plugin-vue'
import { defineConfig } from 'eslint/config'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import vueParser from 'vue-eslint-parser'
const extraFileExtensions = ['.vue']
export default defineConfig([
{
ignores: [
'src/scripts/*',
'src/extensions/core/*',
'src/types/vue-shim.d.ts',
'src/types/comfyRegistryTypes.ts',
'src/types/generatedManagerTypes.ts',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*'
]
},
{
files: ['./**/*.{ts,mts}'],
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
},
parserOptions: {
parser: tseslint.parser,
projectService: true,
tsConfigRootDir: import.meta.dirname,
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions
}
}
},
{
files: ['./**/*.vue'],
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
},
parser: vueParser,
parserOptions: {
parser: tseslint.parser,
projectService: true,
tsConfigRootDir: import.meta.dirname,
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions
}
}
},
pluginJs.configs.recommended,
tseslint.configs.recommended,
pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
storybook.configs['flat/recommended'],
{
plugins: {
'unused-imports': unusedImports,
// @ts-expect-error Bad types in the plugin
'@intlify/vue-i18n': pluginI18n
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error',
'unused-imports/no-unused-imports': 'error',
'vue/no-v-html': 'off',
// Enforce dark-theme: instead of dark: prefix
'vue/no-restricted-class': ['error', '/^dark:/'],
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
'vue/one-component-per-file': 'off', // TODO: fix
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components
'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"'
}
]
}
],
// i18n rules
'@intlify/vue-i18n/no-raw-text': [
'error',
{
// Ignore strings that are:
// 1. Less than 2 characters
// 2. Only symbols/numbers/whitespace (no letters)
// 3. Match specific patterns
ignorePattern:
'^[^a-zA-Z]*$|^.{0,1}$|^[\\w._%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$',
ignoreNodes: ['md-icon', 'v-icon', 'pre', 'code', 'script', 'style'],
// Brand names and technical terms that shouldn't be translated
ignoreText: [
'ComfyUI',
'GitHub',
'OpenAI',
'API',
'URL',
'JSON',
'YAML',
'GPU',
'CPU',
'RAM',
'GB',
'MB',
'KB',
'ms',
'fps',
'px',
'App Data:',
'App Path:'
]
}
]
}
},
{
files: ['tests-ui/**/*'],
rules: {
'@typescript-eslint/consistent-type-imports': [
'error',
{ disallowTypeAnnotations: false }
]
}
}
])

View File

@@ -2,58 +2,84 @@ import type { KnipConfig } from 'knip'
const config: KnipConfig = {
entry: [
'{build,scripts}/**/*.{js,ts}',
'src/assets/css/style.css',
'build/**/*.ts',
'scripts/**/*.{js,ts}',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts'
'vite.electron.config.mts',
'vite.types.config.mts'
],
project: [
'browser_tests/**/*.{js,ts}',
'build/**/*.{js,ts,vue}',
'scripts/**/*.{js,ts}',
'src/**/*.{js,ts,vue}',
'tests-ui/**/*.{js,ts,vue}',
'*.{js,ts,mts}'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}'],
ignoreBinaries: ['only-allow', 'openapi-typescript'],
ignoreDependencies: [
// Weird importmap things
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons',
'@iconify/json',
'tailwindcss',
'tailwindcss-primeui', // Need to figure out why tailwind plugin isn't applying
// Dev
'@trivago/prettier-plugin-sort-imports'
],
ignore: [
// Generated files
'dist/**',
'types/**',
'node_modules/**',
// Config files that might not show direct usage
'.husky/**',
// Temporary or cache files
'.vite/**',
'coverage/**',
// i18n config
'.i18nrc.cjs',
// Vitest litegraph config
'vitest.litegraph.config.ts',
// Test setup files
'browser_tests/globalSetup.ts',
'browser_tests/globalTeardown.ts',
'browser_tests/utils/**',
// Scripts
'scripts/**',
// Vite config files
'vite.electron.config.mts',
'vite.types.config.mts',
// Auto generated manager types
'src/types/generatedManagerTypes.ts',
'src/types/comfyRegistryTypes.ts',
// Design system components (may not be used immediately)
'src/components/button/IconGroup.vue',
'src/components/button/MoreButton.vue',
'src/components/button/TextButton.vue',
'src/components/card/CardTitle.vue',
'src/components/card/CardDescription.vue',
'src/components/input/SingleSelect.vue',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Staged for for use with subgraph widget promotion
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts'
// Generated file: openapi
'src/types/comfyRegistryTypes.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
css: (text: string) =>
[
...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)
].join('\n')
},
vite: {
config: ['vite?(.*).config.mts']
},
vitest: {
config: ['vitest?(.*).config.ts'],
entry: [
'**/*.{bench,test,test-d,spec}.?(c|m)[jt]s?(x)',
'**/__mocks__/**/*.[jt]s?(x)'
]
},
playwright: {
config: ['playwright?(.*).config.ts'],
entry: ['**/*.@(spec|test).?(c|m)[jt]s?(x)', 'browser_tests/**/*.ts']
ignoreExportsUsedInFile: true,
// Vue-specific configuration
vue: true,
tailwind: true,
// Only check for unused files, disable all other rules
// TODO: Gradually enable other rules - see https://github.com/Comfy-Org/ComfyUI_frontend/issues/4888
rules: {
classMembers: 'off'
},
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch'
]
],
// Include dependencies analysis
includeEntryExports: true
}
export default config

View File

@@ -3,13 +3,13 @@ export default {
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
'vue-tsc --noEmit'
]
}
function formatAndEslint(fileNames) {
return [
`pnpm exec eslint --cache --fix ${fileNames.join(' ')}`,
`pnpm exec prettier --cache --write ${fileNames.join(' ')}`
`eslint --fix ${fileNames.join(' ')}`,
`prettier --write ${fileNames.join(' ')}`
]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.28.0",
"version": "1.27.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -14,9 +14,9 @@
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "vue-tsc --noEmit",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"test:browser": "npx nx e2e",
"test:unit": "nx run test tests-ui/tests",
@@ -38,10 +38,9 @@
"build-storybook": "storybook build"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@iconify-json/lucide": "^1.2.66",
"@eslint/js": "^9.8.0",
"@iconify/tailwind": "^1.2.0",
"@intlify/eslint-plugin-vue-i18n": "^4.1.0",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.25.1",
"@nx/eslint": "21.4.1",
"@nx/playwright": "21.4.1",
@@ -63,12 +62,12 @@
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.0.0",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-storybook": "^9.1.6",
"eslint-plugin-unused-imports": "^4.2.0",
"eslint-plugin-vue": "^10.4.0",
"eslint": "^9.12.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-storybook": "^9.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-vue": "^9.27.0",
"fs-extra": "^11.2.0",
"globals": "^15.9.0",
"happy-dom": "^15.11.0",
@@ -77,32 +76,31 @@
"jsdom": "^26.1.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"lucide-vue-next": "^0.540.0",
"nx": "21.4.1",
"prettier": "^3.3.2",
"storybook": "^9.1.6",
"storybook": "^9.1.1",
"tailwindcss": "^4.1.12",
"tailwindcss-primeui": "^0.6.1",
"tsx": "^4.15.6",
"tw-animate-css": "^1.3.8",
"typescript": "^5.4.5",
"typescript-eslint": "^8.44.0",
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.22.0",
"unplugin-vue-components": "^0.28.0",
"uuid": "^11.1.0",
"vite": "^5.4.19",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^3.2.4",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.7",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1"
},
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/comfyui-electron-types": "^0.4.69",
"@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2",
"@primeuix/styled": "0.3.2",
@@ -142,7 +140,6 @@
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"reka-ui": "^2.5.0",
"semver": "^7.7.2",
"tailwind-merge": "^3.3.1",
"three": "^0.170.0",

1491
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,183 +0,0 @@
#!/usr/bin/env tsx
import fs from 'fs'
import path from 'path'
interface TestStats {
expected?: number
unexpected?: number
flaky?: number
skipped?: number
finished?: number
}
interface ReportData {
stats?: TestStats
}
interface TestCounts {
passed: number
failed: number
flaky: number
skipped: number
total: number
}
/**
* Extract test counts from Playwright HTML report
* @param reportDir - Path to the playwright-report directory
* @returns Test counts { passed, failed, flaky, skipped, total }
*/
function extractTestCounts(reportDir: string): TestCounts {
const counts: TestCounts = {
passed: 0,
failed: 0,
flaky: 0,
skipped: 0,
total: 0
}
try {
// First, try to find report.json which Playwright generates with JSON reporter
const jsonReportFile = path.join(reportDir, 'report.json')
if (fs.existsSync(jsonReportFile)) {
const reportJson: ReportData = JSON.parse(
fs.readFileSync(jsonReportFile, 'utf-8')
)
if (reportJson.stats) {
const stats = reportJson.stats
counts.total = stats.expected || 0
counts.passed =
(stats.expected || 0) -
(stats.unexpected || 0) -
(stats.flaky || 0) -
(stats.skipped || 0)
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
return counts
}
}
// Try index.html - Playwright HTML report embeds data in a script tag
const indexFile = path.join(reportDir, 'index.html')
if (fs.existsSync(indexFile)) {
const content = fs.readFileSync(indexFile, 'utf-8')
// Look for the embedded report data in various formats
// Format 1: window.playwrightReportBase64
let dataMatch = content.match(
/window\.playwrightReportBase64\s*=\s*["']([^"']+)["']/
)
if (dataMatch) {
try {
const decodedData = Buffer.from(dataMatch[1], 'base64').toString(
'utf-8'
)
const reportData: ReportData = JSON.parse(decodedData)
if (reportData.stats) {
const stats = reportData.stats
counts.total = stats.expected || 0
counts.passed =
(stats.expected || 0) -
(stats.unexpected || 0) -
(stats.flaky || 0) -
(stats.skipped || 0)
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
return counts
}
} catch (e) {
// Continue to try other formats
}
}
// Format 2: window.playwrightReport
dataMatch = content.match(/window\.playwrightReport\s*=\s*({[\s\S]*?});/)
if (dataMatch) {
try {
// Use Function constructor instead of eval for safety
const reportData = new Function(
'return ' + dataMatch[1]
)() as ReportData
if (reportData.stats) {
const stats = reportData.stats
counts.total = stats.expected || 0
counts.passed =
(stats.expected || 0) -
(stats.unexpected || 0) -
(stats.flaky || 0) -
(stats.skipped || 0)
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
return counts
}
} catch (e) {
// Continue to try other formats
}
}
// Format 3: Look for stats in the HTML content directly
// Playwright sometimes renders stats in the UI
const statsMatch = content.match(
/(\d+)\s+passed[^0-9]*(\d+)\s+failed[^0-9]*(\d+)\s+flaky[^0-9]*(\d+)\s+skipped/i
)
if (statsMatch) {
counts.passed = parseInt(statsMatch[1]) || 0
counts.failed = parseInt(statsMatch[2]) || 0
counts.flaky = parseInt(statsMatch[3]) || 0
counts.skipped = parseInt(statsMatch[4]) || 0
counts.total =
counts.passed + counts.failed + counts.flaky + counts.skipped
return counts
}
// Format 4: Try to extract from summary text patterns
const passedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+passed/i)
const failedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+failed/i)
const flakyMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+flaky/i)
const skippedMatch = content.match(/(\d+)\s+(?:tests?|specs?)\s+skipped/i)
const totalMatch = content.match(
/(\d+)\s+(?:tests?|specs?)\s+(?:total|ran)/i
)
if (passedMatch) counts.passed = parseInt(passedMatch[1]) || 0
if (failedMatch) counts.failed = parseInt(failedMatch[1]) || 0
if (flakyMatch) counts.flaky = parseInt(flakyMatch[1]) || 0
if (skippedMatch) counts.skipped = parseInt(skippedMatch[1]) || 0
if (totalMatch) {
counts.total = parseInt(totalMatch[1]) || 0
} else if (
counts.passed ||
counts.failed ||
counts.flaky ||
counts.skipped
) {
counts.total =
counts.passed + counts.failed + counts.flaky + counts.skipped
}
}
} catch (error) {
console.error(`Error reading report from ${reportDir}:`, error)
}
return counts
}
// Main execution
const reportDir = process.argv[2]
if (!reportDir) {
console.error('Usage: extract-playwright-counts.ts <report-directory>')
process.exit(1)
}
const counts = extractTestCounts(reportDir)
// Output as JSON for easy parsing in shell script
console.log(JSON.stringify(counts))
export { extractTestCounts }

View File

@@ -1,377 +0,0 @@
#!/bin/bash
set -e
# Deploy Playwright test reports to Cloudflare Pages and comment on PR
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
# Input validation
# Validate PR number is numeric
case "$1" in
''|*[!0-9]*)
echo "Error: PR_NUMBER must be numeric" >&2
exit 1
;;
esac
PR_NUMBER="$1"
# Sanitize and validate branch name (allow alphanumeric, dots, dashes, underscores, slashes)
BRANCH_NAME=$(echo "$2" | sed 's/[^a-zA-Z0-9._/-]//g')
if [ -z "$BRANCH_NAME" ]; then
echo "Error: Invalid or empty branch name" >&2
exit 1
fi
# Validate status parameter
STATUS="${3:-completed}"
case "$STATUS" in
starting|completed) ;;
*)
echo "Error: STATUS must be 'starting' or 'completed'" >&2
exit 1
;;
esac
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
# Cloudflare variables only required for deployment
if [ "$STATUS" = "completed" ]; then
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
fi
# Configuration
COMMENT_MARKER="<!-- PLAYWRIGHT_TEST_STATUS -->"
# Use dot notation for artifact names (as Playwright creates them)
BROWSERS="chromium chromium-2x chromium-0.5x mobile-chrome"
# Install wrangler if not available (output to stderr for debugging)
if ! command -v wrangler > /dev/null 2>&1; then
echo "Installing wrangler v4..." >&2
npm install -g wrangler@^4.0.0 >&2 || {
echo "Failed to install wrangler" >&2
echo "failed"
return
}
fi
# Check if tsx is available, install if not
if ! command -v tsx > /dev/null 2>&1; then
echo "Installing tsx..." >&2
npm install -g tsx >&2 || echo "Failed to install tsx" >&2
fi
# Deploy a single browser report, WARN: ensure inputs are sanitized before calling this function
deploy_report() {
dir="$1"
browser="$2"
branch="$3"
[ ! -d "$dir" ] && echo "failed" && return
# Project name with dots converted to dashes for Cloudflare
sanitized_browser=$(echo "$browser" | sed 's/\./-/g')
project="comfyui-playwright-${sanitized_browser}"
echo "Deploying $browser to project $project on branch $branch..." >&2
# Try deployment up to 3 times
i=1
while [ $i -le 3 ]; do
echo "Deployment attempt $i of 3..." >&2
# Branch and project are already sanitized, use them directly
# Branch was sanitized at script start, project uses sanitized_browser
if output=$(wrangler pages deploy "$dir" \
--project-name="$project" \
--branch="$branch" 2>&1); then
# Extract URL from output (improved regex for valid URL characters)
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
result="${url:-https://${branch}.${project}.pages.dev}"
echo "Success! URL: $result" >&2
echo "$result" # Only this goes to stdout for capture
return
else
echo "Deployment failed on attempt $i: $output" >&2
fi
[ $i -lt 3 ] && sleep 10
i=$((i + 1))
done
echo "failed"
}
# Post or update GitHub comment
post_comment() {
body="$1"
temp_file=$(mktemp)
echo "$body" > "$temp_file"
if command -v gh > /dev/null 2>&1; then
# Find existing comment ID
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
if [ -n "$existing" ]; then
# Update specific comment by ID
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
--field body="$(cat "$temp_file")"
else
# Create new comment
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
fi
else
echo "GitHub CLI not available, outputting comment:"
cat "$temp_file"
fi
rm -f "$temp_file"
}
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post starting comment
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎭 Playwright Test Results
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Running Tests
- 🧪 **chromium**: Running tests...
- 🧪 **chromium-0.5x**: Running tests...
- 🧪 **chromium-2x**: Running tests...
- 🧪 **mobile-chrome**: Running tests...
---
⏱️ Please wait while tests are running...
EOF
)
post_comment "$comment"
else
# Deploy and post completion comment
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
echo "Looking for reports in: $(pwd)/reports"
echo "Available reports:"
ls -la reports/ 2>/dev/null || echo "Reports directory not found"
# Deploy all reports in parallel and collect URLs + test counts
temp_dir=$(mktemp -d)
pids=""
i=0
# Store current working directory for absolute paths
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BASE_DIR="$(pwd)"
# Start parallel deployments and count extractions
for browser in $BROWSERS; do
if [ -d "reports/playwright-report-$browser" ]; then
echo "Found report for $browser, deploying in parallel..."
(
url=$(deploy_report "reports/playwright-report-$browser" "$browser" "$cloudflare_branch")
echo "$url" > "$temp_dir/$i.url"
echo "Deployment result for $browser: $url"
# Extract test counts using tsx (TypeScript executor)
EXTRACT_SCRIPT="$SCRIPT_DIR/extract-playwright-counts.ts"
REPORT_DIR="$BASE_DIR/reports/playwright-report-$browser"
if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then
echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}')
echo "Extracted counts for $browser: $counts" >&2
echo "$counts" > "$temp_dir/$i.counts"
else
echo "Script not found or tsx not available: $EXTRACT_SCRIPT" >&2
echo '{}' > "$temp_dir/$i.counts"
fi
) &
pids="$pids $!"
else
echo "Report not found for $browser at reports/playwright-report-$browser"
echo "failed" > "$temp_dir/$i.url"
echo '{}' > "$temp_dir/$i.counts"
fi
i=$((i + 1))
done
# Wait for all deployments to complete
for pid in $pids; do
wait $pid
done
# Collect URLs and counts in order
urls=""
all_counts=""
i=0
for browser in $BROWSERS; do
if [ -f "$temp_dir/$i.url" ]; then
url=$(cat "$temp_dir/$i.url")
else
url="failed"
fi
if [ -z "$urls" ]; then
urls="$url"
else
urls="$urls $url"
fi
if [ -f "$temp_dir/$i.counts" ]; then
counts=$(cat "$temp_dir/$i.counts")
echo "Read counts for $browser from $temp_dir/$i.counts: $counts" >&2
else
counts="{}"
echo "No counts file found for $browser at $temp_dir/$i.counts" >&2
fi
if [ -z "$all_counts" ]; then
all_counts="$counts"
else
all_counts="$all_counts|$counts"
fi
i=$((i + 1))
done
# Clean up temp directory
rm -rf "$temp_dir"
# Calculate total test counts across all browsers
total_passed=0
total_failed=0
total_flaky=0
total_skipped=0
total_tests=0
# Parse counts and calculate totals
IFS='|'
set -- $all_counts
for counts_json; do
if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then
# Parse JSON counts using simple grep/sed if jq is not available
if command -v jq > /dev/null 2>&1; then
passed=$(echo "$counts_json" | jq -r '.passed // 0')
failed=$(echo "$counts_json" | jq -r '.failed // 0')
flaky=$(echo "$counts_json" | jq -r '.flaky // 0')
skipped=$(echo "$counts_json" | jq -r '.skipped // 0')
total=$(echo "$counts_json" | jq -r '.total // 0')
else
# Fallback parsing without jq
passed=$(echo "$counts_json" | sed -n 's/.*"passed":\([0-9]*\).*/\1/p')
failed=$(echo "$counts_json" | sed -n 's/.*"failed":\([0-9]*\).*/\1/p')
flaky=$(echo "$counts_json" | sed -n 's/.*"flaky":\([0-9]*\).*/\1/p')
skipped=$(echo "$counts_json" | sed -n 's/.*"skipped":\([0-9]*\).*/\1/p')
total=$(echo "$counts_json" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')
fi
total_passed=$((total_passed + ${passed:-0}))
total_failed=$((total_failed + ${failed:-0}))
total_flaky=$((total_flaky + ${flaky:-0}))
total_skipped=$((total_skipped + ${skipped:-0}))
total_tests=$((total_tests + ${total:-0}))
fi
done
unset IFS
# Determine overall status
if [ $total_failed -gt 0 ]; then
status_icon="❌"
status_text="Some tests failed"
elif [ $total_flaky -gt 0 ]; then
status_icon="⚠️"
status_text="Tests passed with flaky tests"
elif [ $total_tests -gt 0 ]; then
status_icon="✅"
status_text="All tests passed!"
else
status_icon="🕵🏻"
status_text="No test results found"
fi
# Generate completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Test Results
$status_icon **$status_text**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
# Add summary counts if we have test data
if [ $total_tests -gt 0 ]; then
comment="$comment
### 📈 Summary
- **Total Tests:** $total_tests
- **Passed:** $total_passed
- **Failed:** $total_failed $([ $total_failed -gt 0 ] && echo '❌' || echo '')
- **Flaky:** $total_flaky $([ $total_flaky -gt 0 ] && echo '⚠️' || echo '')
- **Skipped:** $total_skipped $([ $total_skipped -gt 0 ] && echo '⏭️' || echo '')"
fi
comment="$comment
### 📊 Test Reports by Browser"
# Add browser results with individual counts
i=0
IFS='|'
set -- $all_counts
for counts_json; do
# Get browser name
browser=$(echo "$BROWSERS" | cut -d' ' -f$((i + 1)))
# Get URL at position i
url=$(echo "$urls" | cut -d' ' -f$((i + 1)))
if [ "$url" != "failed" ] && [ -n "$url" ]; then
# Parse individual browser counts
if [ "$counts_json" != "{}" ] && [ -n "$counts_json" ]; then
if command -v jq > /dev/null 2>&1; then
b_passed=$(echo "$counts_json" | jq -r '.passed // 0')
b_failed=$(echo "$counts_json" | jq -r '.failed // 0')
b_flaky=$(echo "$counts_json" | jq -r '.flaky // 0')
b_skipped=$(echo "$counts_json" | jq -r '.skipped // 0')
b_total=$(echo "$counts_json" | jq -r '.total // 0')
else
b_passed=$(echo "$counts_json" | sed -n 's/.*"passed":\([0-9]*\).*/\1/p')
b_failed=$(echo "$counts_json" | sed -n 's/.*"failed":\([0-9]*\).*/\1/p')
b_flaky=$(echo "$counts_json" | sed -n 's/.*"flaky":\([0-9]*\).*/\1/p')
b_skipped=$(echo "$counts_json" | sed -n 's/.*"skipped":\([0-9]*\).*/\1/p')
b_total=$(echo "$counts_json" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')
fi
if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then
counts_str=" • ✅ $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped"
else
counts_str=""
fi
else
counts_str=""
fi
comment="$comment
- ✅ **${browser}**: [View Report](${url})${counts_str}"
else
comment="$comment
- ❌ **${browser}**: Deployment failed"
fi
i=$((i + 1))
done
unset IFS
comment="$comment
---
🎉 Click on the links above to view detailed test results for each browser configuration."
post_comment "$comment"
fi

View File

@@ -2,10 +2,9 @@ import * as fs from 'fs'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs'
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
import type { FormItem, SettingParams } from '../src/platform/settings/types'
import type { ComfyCommandImpl } from '../src/stores/commandStore'
import type { FormItem, SettingParams } from '../src/types/settingTypes'
import { formatCamelCase, normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json'
@@ -132,23 +131,6 @@ test('collect-i18n-general', async ({ comfyPage }) => {
])
)
// Desktop Dialogs
const allDesktopDialogsLocale = Object.fromEntries(
Object.values(DESKTOP_DIALOGS).map((dialog) => [
normalizeI18nKey(dialog.id),
{
title: dialog.title,
message: dialog.message,
buttons: Object.fromEntries(
dialog.buttons.map((button) => [
normalizeI18nKey(button.label),
button.label
])
)
}
])
)
fs.writeFileSync(
localePath,
JSON.stringify(
@@ -162,8 +144,7 @@ test('collect-i18n-general', async ({ comfyPage }) => {
...allSettingCategoriesLocale
},
serverConfigItems: allServerConfigsLocale,
serverConfigCategories: allServerConfigCategoriesLocale,
desktopDialogs: allDesktopDialogsLocale
serverConfigCategories: allServerConfigCategoriesLocale
},
null,
2

View File

@@ -5,7 +5,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema'
import {
zComfyWorkflow,
zComfyWorkflow1
} from '../src/platform/workflow/validation/schemas/workflowSchema'
} from '../src/schemas/comfyWorkflowSchema'
import { zComfyNodeDef as zComfyNodeDefV2 } from '../src/schemas/nodeDef/nodeDefSchemaV2'
import { zComfyNodeDef as zComfyNodeDefV1 } from '../src/schemas/nodeDefSchema'

View File

@@ -15,10 +15,10 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from './composables/useConflictDetection'
import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()

View File

@@ -1,17 +0,0 @@
/* Inter Font Family */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-normal.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-italic.woff2') format('woff2');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}

View File

@@ -1,14 +1,72 @@
@layer theme, base, primevue, components, utilities;
@import './fonts.css';
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/utilities' layer(utilities);
@import 'tw-animate-css';
@plugin 'tailwindcss-primeui';
@plugin "tailwindcss-primeui";
@config '../../../tailwind.config.ts';
@layer tailwind-utilities {
/* Set default values to prevent some styles from not working properly. */
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(66 153 225 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
@tailwind components;
@tailwind utilities;
}
:root {
--fg-color: #000;
--bg-color: #fff;
@@ -49,109 +107,6 @@
}
}
@theme {
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
--color-charcoal-500: #2d2e32;
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-stone-100: #444444;
--color-stone-200: #828282;
--color-stone-300: #bbbbbb;
--color-ivory-100: #fdfbfa;
--color-ivory-200: #faf9f5;
--color-ivory-300: #f0eee6;
--color-gray-100: #f3f3f3;
--color-gray-200: #e9e9e9;
--color-gray-300: #e1e1e1;
--color-gray-400: #d9d9d9;
--color-gray-500: #c5c5c5;
--color-gray-600: #b4b4b4;
--color-gray-700: #a0a0a0;
--color-gray-800: #8a8a8a;
--color-sand-100: #e1ded5;
--color-sand-200: #d6cfc2;
--color-sand-300: #888682;
--color-slate-100: #9c9eab;
--color-slate-200: #9fa2bd;
--color-slate-300: #5b5e7d;
--color-brand-yellow: #f0ff41;
--color-brand-blue: #172dd7;
--color-blue-100: #0b8ce9;
--color-blue-200: #31b9f4;
--color-success-100: #00cd72;
--color-success-200: #47e469;
--color-warning-100: #fd9903;
--color-warning-200: #fcbf64;
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-coral-red-600: #973a40;
--color-coral-red-500: #c53f49;
--color-coral-red-400: #dd424e;
--color-bypass: #6a246a;
--color-error: #962a2a;
--color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4);
/* PrimeVue pulled colors */
--color-muted: var(--p-text-muted-color);
--color-highlight: var(--p-primary-color);
/* Special Colors (temporary) */
--color-dark-elevation-1.5: rgba(from white r g b/ 0.015);
--color-dark-elevation-2: rgba(from white r g b / 0.03);
}
@theme inline {
--color-node-component-surface: var(--color-charcoal-600);
--color-node-component-surface-highlight: var(--color-slate-100);
--color-node-component-surface-hovered: var(--color-charcoal-400);
--color-node-component-surface-selected: var(--color-charcoal-200);
--color-node-stroke: var(--color-stone-100);
}
@custom-variant dark-theme {
.dark-theme & {
@slot;
}
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
/* Everthing below here to be cleaned up over time. */
body {
width: 100vw;
height: 100vh;
@@ -894,7 +849,7 @@ audio.comfy-audio.empty-audio-widget {
.comfy-load-3d,
.comfy-load-3d-animation,
.comfy-preview-3d,
.comfy-preview-3d-animation {
.comfy-preview-3d-animation{
display: flex;
flex-direction: column;
background: transparent;
@@ -907,7 +862,7 @@ audio.comfy-audio.empty-audio-widget {
.comfy-load-3d-animation canvas,
.comfy-preview-3d canvas,
.comfy-preview-3d-animation canvas,
.comfy-load-3d-viewer canvas {
.comfy-load-3d-viewer canvas{
display: flex;
width: 100% !important;
height: 100% !important;
@@ -972,7 +927,9 @@ audio.comfy-audio.empty-audio-widget {
/* Uses default styling - no overrides needed */
}
/* Smooth transitions between LOD levels */
.lg-node {
transition: min-height 0.2s ease;
/* Disable text selection on all nodes */
user-select: none;
-webkit-user-select: none;
@@ -982,9 +939,7 @@ audio.comfy-audio.empty-audio-widget {
.lg-node .lg-slot,
.lg-node .lg-widget {
transition:
opacity 0.1s ease,
font-size 0.1s ease;
transition: opacity 0.1s ease, font-size 0.1s ease;
}
/* Performance optimization during canvas interaction */
@@ -1016,3 +971,4 @@ audio.comfy-audio.empty-audio-widget {
/* Use solid colors only */
background-image: none !important;
}

View File

@@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" class="" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_704_2695)">
<path d="M6.05048 2C5.52055 7.29512 9.23033 10.4722 14 9.94267" stroke="#9C9EAB" stroke-width="1.3"/>
<path d="M6.5 5.5L10 2" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round"/>
<path d="M8 8L12.5 3.5" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="square"/>
<path d="M10.5 9.5L14 6" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round"/>
<path d="M7.99992 14.6667C11.6818 14.6667 14.6666 11.6819 14.6666 8.00004C14.6666 4.31814 11.6818 1.33337 7.99992 1.33337C4.31802 1.33337 1.33325 4.31814 1.33325 8.00004C1.33325 11.6819 4.31802 14.6667 7.99992 14.6667Z" stroke="#9C9EAB" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_704_2695">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 938 B

View File

@@ -1,41 +0,0 @@
/**
* Utility functions for downloading files
*/
// Constants
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
/**
* Download a file from a URL by creating a temporary anchor element
* @param url - The URL of the file to download (must be a valid URL string)
* @param filename - Optional filename override (will use URL filename or default if not provided)
* @throws {Error} If the URL is invalid or empty
*/
export const downloadFile = (url: string, filename?: string): void => {
if (!url || typeof url !== 'string' || url.trim().length === 0) {
throw new Error('Invalid URL provided for download')
}
const link = document.createElement('a')
link.href = url
link.download =
filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
// Trigger download
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
/**
* Extract filename from a URL's query parameters
* @param url - The URL to extract filename from
* @returns The extracted filename or null if not found
*/
const extractFilenameFromUrl = (url: string): string | null => {
try {
const urlObj = new URL(url, window.location.origin)
return urlObj.searchParams.get('filename')
} catch {
return null
}
}

View File

@@ -50,7 +50,7 @@ import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/stores/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'

View File

@@ -21,11 +21,10 @@
<script setup lang="ts">
import Button from 'primevue/button'
import type { CSSProperties } from 'vue'
import { computed, watchEffect } from 'vue'
import { CSSProperties, computed, watchEffect } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { showNativeSystemMenu } from '@/utils/envUtil'

View File

@@ -37,8 +37,8 @@ import { storeToRefs } from 'pinia'
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)

View File

@@ -22,10 +22,9 @@ import {
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import Panel from 'primevue/panel'
import type { Ref } from 'vue'
import { computed, inject, nextTick, onMounted, ref, watch } from 'vue'
import { Ref, computed, inject, nextTick, onMounted, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/stores/settingStore'
import ComfyQueueButton from './ComfyQueueButton.vue'
@@ -38,7 +37,7 @@ const visible = computed(() => position.value !== 'Disabled')
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0

View File

@@ -3,38 +3,13 @@
<div class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" />
</div>
<Button
v-tooltip.left="{
value: tooltipText,
showDelay: 300
}"
icon="pi pi-copy"
severity="secondary"
size="small"
:class="
cn('absolute top-2 right-8 transition-opacity', {
'opacity-0 pointer-events-none select-none': !isHovered
})
"
:aria-label="tooltipText"
@click="handleCopy"
/>
</div>
</template>
<script setup lang="ts">
import { useElementHover, useEventListener } from '@vueuse/core'
import type { IDisposable } from '@xterm/xterm'
import Button from 'primevue/button'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Ref, onUnmounted, ref } from 'vue'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const emit = defineEmits<{
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement | undefined>]
@@ -42,59 +17,9 @@ const emit = defineEmits<{
}>()
const terminalEl = ref<HTMLElement | undefined>()
const rootEl = ref<HTMLElement | undefined>()
const hasSelection = ref(false)
emit('created', useTerminal(terminalEl), rootEl)
const isHovered = useElementHover(rootEl)
const terminalData = useTerminal(terminalEl)
emit('created', terminalData, ref(rootEl))
const { terminal } = terminalData
let selectionDisposable: IDisposable | undefined
const tooltipText = computed(() => {
return hasSelection.value
? t('serverStart.copySelectionTooltip')
: t('serverStart.copyAllTooltip')
})
const handleCopy = async () => {
const existingSelection = terminal.getSelection()
const shouldSelectAll = !existingSelection
if (shouldSelectAll) terminal.selectAll()
const selectedText = shouldSelectAll
? terminal.getSelection()
: existingSelection
if (selectedText) {
await navigator.clipboard.writeText(selectedText)
if (shouldSelectAll) {
terminal.clearSelection()
}
}
}
const showContextMenu = (event: MouseEvent) => {
event.preventDefault()
electronAPI()?.showContextMenu({ type: 'text' })
}
if (isElectron()) {
useEventListener(terminalEl, 'contextmenu', showContextMenu)
}
onMounted(() => {
selectionDisposable = terminal.onSelectionChange(() => {
hasSelection.value = terminal.hasSelection()
})
})
onUnmounted(() => {
selectionDisposable?.dispose()
emit('unmounted')
})
onUnmounted(() => emit('unmounted'))
</script>
<style scoped>

View File

@@ -3,9 +3,8 @@
</template>
<script setup lang="ts">
import type { IDisposable } from '@xterm/xterm'
import type { Ref } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { IDisposable } from '@xterm/xterm'
import { Ref, onMounted, onUnmounted } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI } from '@/utils/envUtil'

View File

@@ -15,11 +15,10 @@
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { Ref, onMounted, onUnmounted, ref } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import type { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
import { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'

View File

@@ -38,10 +38,9 @@ import { computed, onUpdated, ref, watch } from 'vue'
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
const MIN_WIDTH = 28
@@ -53,9 +52,6 @@ const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
)
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
@@ -93,7 +89,6 @@ const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
isBlueprint: isBlueprint.value,
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -16,7 +16,6 @@
@click="handleClick"
>
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
@@ -47,21 +46,16 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import type { MenuState } from 'primevue/menu'
import Menu from 'primevue/menu'
import Menu, { MenuState } from 'primevue/menu'
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { appendJsonExt } from '@/utils/formatUtil'
interface Props {
@@ -127,7 +121,7 @@ const menuItems = computed<MenuItem[]>(() => {
command: async () => {
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
},
visible: isRoot && !props.item.isBlueprint
visible: isRoot
},
{
separator: true,
@@ -159,26 +153,12 @@ const menuItems = computed<MenuItem[]>(() => {
await useCommandStore().execute('Comfy.ClearWorkflow')
}
},
{
separator: true,
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
label: t('subgraphStore.publish'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
separator: true,
visible: isRoot
},
{
label: props.item.isBlueprint
? t('breadcrumbsMenu.deleteBlueprint')
: t('breadcrumbsMenu.deleteWorkflow'),
label: t('breadcrumbsMenu.deleteWorkflow'),
icon: 'pi pi-times',
command: async () => {
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)

View File

@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
@@ -32,13 +33,13 @@ type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconButton },
components: { IconButton, Trophy },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<i class="icon-[lucide--trophy] size-4" />
<Trophy :size="16" />
</IconButton>
`
}),
@@ -50,13 +51,13 @@ export const Primary: Story = {
export const Secondary: Story = {
render: (args) => ({
components: { IconButton },
components: { IconButton, Settings },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<i class="icon-[lucide--settings] size-4" />
<Settings :size="16" />
</IconButton>
`
}),
@@ -68,13 +69,13 @@ export const Secondary: Story = {
export const Transparent: Story = {
render: (args) => ({
components: { IconButton },
components: { IconButton, X },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<i class="icon-[lucide--x] size-4" />
<X :size="16" />
</IconButton>
`
}),
@@ -86,13 +87,13 @@ export const Transparent: Story = {
export const Small: Story = {
render: (args) => ({
components: { IconButton },
components: { IconButton, Bell },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<i class="icon-[lucide--bell] size-3" />
<Bell :size="12" />
</IconButton>
`
}),
@@ -104,42 +105,42 @@ export const Small: Story = {
export const AllVariants: Story = {
render: () => ({
components: { IconButton },
components: { IconButton, Trophy, Settings, X, Bell, Heart, Download },
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconButton type="primary" size="sm" @click="() => {}">
<i class="icon-[lucide--trophy] size-3" />
<Trophy :size="12" />
</IconButton>
<IconButton type="primary" size="md" @click="() => {}">
<i class="icon-[lucide--trophy] size-4" />
<Trophy :size="16" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="secondary" size="sm" @click="() => {}">
<i class="icon-[lucide--settings] size-3" />
<Settings :size="12" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<i class="icon-[lucide--settings] size-4" />
<Settings :size="16" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="transparent" size="sm" @click="() => {}">
<i class="icon-[lucide--x] size-3" />
<X :size="12" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<i class="icon-[lucide--x] size-4" />
<X :size="16" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="primary" size="md" @click="() => {}">
<i class="icon-[lucide--bell] size-4" />
<Bell :size="16" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<i class="icon-[lucide--heart] size-4" />
<Heart :size="16" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<i class="icon-[lucide--download] size-4" />
<Download :size="16" />
</IconButton>
</div>
</div>

View File

@@ -1,11 +1,5 @@
<template>
<Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
<slot></slot>
</Button>
</template>
@@ -21,16 +15,11 @@ import {
getButtonTypeClasses,
getIconButtonSizeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
interface IconButtonProps extends BaseButtonProps {
onClick: (event: Event) => void
}
defineOptions({
inheritAttrs: false
})
const {
size = 'md',
type = 'secondary',
@@ -47,6 +36,8 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)
return cn(baseClasses, sizeClasses, typeClasses, className)
return [baseClasses, sizeClasses, typeClasses, className]
.filter(Boolean)
.join(' ')
})
</script>

View File

@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ExternalLink, Heart } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
import IconGroup from './IconGroup.vue'
@@ -16,17 +17,17 @@ type Story = StoryObj<typeof IconGroup>
export const Basic: Story = {
render: () => ({
components: { IconGroup, IconButton },
components: { IconGroup, IconButton, Download, ExternalLink, Heart },
template: `
<IconGroup>
<IconButton @click="console.log('Hello World!!')">
<i class="icon-[lucide--heart] size-4" />
<Heart :size="16" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<i class="icon-[lucide--download] size-4" />
<Download :size="16" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<i class="icon-[lucide--external-link] size-4" />
<ExternalLink :size="16" />
</IconButton>
</IconGroup>
`

View File

@@ -1,17 +1,7 @@
<template>
<div :class="iconGroupClasses">
<div
class="flex justify-center items-center shrink-0 outline-hidden border-none p-0 bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white rounded-lg cursor-pointer"
>
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const iconGroupClasses = cn(
'flex justify-center items-center shrink-0',
'outline-hidden border-none p-0 rounded-lg',
'bg-white dark-theme:bg-zinc-700',
'text-neutral-950 dark-theme:text-white',
'cursor-pointer'
)
</script>

View File

@@ -1,4 +1,14 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
ChevronLeft,
ChevronRight,
Download,
Package,
Save,
Settings,
Trash2,
X
} from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
@@ -39,14 +49,14 @@ type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconTextButton },
components: { IconTextButton, Package },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<i class="icon-[lucide--package] size-4" />
<Package :size="16" />
</template>
</IconTextButton>
`
@@ -60,14 +70,14 @@ export const Primary: Story = {
export const Secondary: Story = {
render: (args) => ({
components: { IconTextButton },
components: { IconTextButton, Settings },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<i class="icon-[lucide--settings] size-4" />
<Settings :size="16" />
</template>
</IconTextButton>
`
@@ -81,14 +91,14 @@ export const Secondary: Story = {
export const Transparent: Story = {
render: (args) => ({
components: { IconTextButton },
components: { IconTextButton, X },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<i class="icon-[lucide--x] size-4" />
<X :size="16" />
</template>
</IconTextButton>
`
@@ -102,14 +112,14 @@ export const Transparent: Story = {
export const WithIconRight: Story = {
render: (args) => ({
components: { IconTextButton },
components: { IconTextButton, ChevronRight },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<i class="icon-[lucide--chevron-right] size-4" />
<ChevronRight :size="16" />
</template>
</IconTextButton>
`
@@ -124,14 +134,14 @@ export const WithIconRight: Story = {
export const Small: Story = {
render: (args) => ({
components: { IconTextButton },
components: { IconTextButton, Save },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<i class="icon-[lucide--save] size-3" />
<Save :size="12" />
</template>
</IconTextButton>
`
@@ -146,60 +156,66 @@ export const Small: Story = {
export const AllVariants: Story = {
render: () => ({
components: {
IconTextButton
IconTextButton,
Download,
Settings,
Trash2,
ChevronRight,
ChevronLeft,
Save
},
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
<template #icon>
<i class="icon-[lucide--download] size-3" />
<Download :size="12" />
</template>
</IconTextButton>
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
<template #icon>
<i class="icon-[lucide--download] size-4" />
<Download :size="16" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
<template #icon>
<i class="icon-[lucide--settings] size-3" />
<Settings :size="12" />
</template>
</IconTextButton>
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
<template #icon>
<i class="icon-[lucide--settings] size-4" />
<Settings :size="16" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
<template #icon>
<i class="icon-[lucide--trash-2] size-3" />
<Trash2 :size="12" />
</template>
</IconTextButton>
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
<template #icon>
<i class="icon-[lucide--trash-2] size-4" />
<Trash2 :size="16" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
<template #icon>
<i class="icon-[lucide--chevron-right] size-4" />
<ChevronRight :size="16" />
</template>
</IconTextButton>
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
<template #icon>
<i class="icon-[lucide--chevron-left] size-4" />
<ChevronLeft :size="16" />
</template>
</IconTextButton>
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
<template #icon>
<i class="icon-[lucide--save] size-4" />
<Save :size="16" />
</template>
</IconTextButton>
</div>

View File

@@ -1,11 +1,5 @@
<template>
<Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
<span>{{ label }}</span>
<slot v-if="iconPosition === 'right'" name="icon"></slot>
@@ -23,11 +17,6 @@ import {
getButtonSizeClasses,
getButtonTypeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
interface IconTextButtonProps extends BaseButtonProps {
iconPosition?: 'left' | 'right'
@@ -53,6 +42,8 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)
return cn(baseClasses, sizeClasses, typeClasses, className)
return [baseClasses, sizeClasses, typeClasses, className]
.filter(Boolean)
.join(' ')
})
</script>

View File

@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ScrollText } from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
import MoreButton from './MoreButton.vue'
@@ -17,7 +18,7 @@ type Story = StoryObj<typeof MoreButton>
export const Basic: Story = {
render: () => ({
components: { MoreButton, IconTextButton },
components: { MoreButton, IconTextButton, Download, ScrollText },
template: `
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
<MoreButton>
@@ -28,7 +29,7 @@ export const Basic: Story = {
@click="() => { close() }"
>
<template #icon>
<i class="icon-[lucide--download] size-4" />
<Download :size="16" />
</template>
</IconTextButton>
@@ -38,7 +39,7 @@ export const Basic: Story = {
@click="() => { close() }"
>
<template #icon>
<i class="icon-[lucide--scroll-text] size-4" />
<ScrollText :size="16" />
</template>
</IconTextButton>
</template>

View File

@@ -14,7 +14,7 @@
unstyled
:pt="pt"
>
<div class="flex flex-col gap-2 p-2 min-w-40">
<div class="flex flex-col gap-1 p-2 min-w-40">
<slot :close="hide" />
</div>
</Popover>
@@ -25,8 +25,6 @@
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import IconButton from './IconButton.vue'
const popover = ref<InstanceType<typeof Popover>>()
@@ -41,16 +39,13 @@ const hide = () => {
const pt = computed(() => ({
root: {
class: cn('absolute z-50')
class: 'absolute z-50'
},
content: {
class: cn(
'mt-2 rounded-lg',
'bg-white dark-theme:bg-zinc-800',
'text-neutral dark-theme:text-white',
'shadow-lg',
'border border-zinc-200 dark-theme:border-zinc-700'
)
class: [
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
]
}
}))
</script>

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