Compare commits
53 Commits
schema-as-
...
drjkl/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78cbfd2a7a | ||
|
|
9a3545b42b | ||
|
|
493fe667b7 | ||
|
|
7636b9eaa1 | ||
|
|
b661412c5b | ||
|
|
ba4c3525f4 | ||
|
|
58024fb4f4 | ||
|
|
ac72999e26 | ||
|
|
e27b5311d1 | ||
|
|
35ddb19a68 | ||
|
|
aed342572c | ||
|
|
f16f5a0bc6 | ||
|
|
4fc7d6352f | ||
|
|
c47cedcec2 | ||
|
|
789617415d | ||
|
|
26cf45ed36 | ||
|
|
af10082134 | ||
|
|
28a779d41a | ||
|
|
ed5d258ab6 | ||
|
|
5d0aee59a6 | ||
|
|
d852bab617 | ||
|
|
dd93ac43d9 | ||
|
|
e0b1a6d212 | ||
|
|
bbd8d67f5f | ||
|
|
5c707a1b93 | ||
|
|
3077f4c4c4 | ||
|
|
9734466a13 | ||
|
|
5d62e01c11 | ||
|
|
8135b67f34 | ||
|
|
1215cb4670 | ||
|
|
a30705161d | ||
|
|
02b3b8ef5b | ||
|
|
aa212bea90 | ||
|
|
a2c7db9dc2 | ||
|
|
840f7f04fa | ||
|
|
042c2caa88 | ||
|
|
cd8b5b5d50 | ||
|
|
a25d89881b | ||
|
|
5b866b74b8 | ||
|
|
e8b9f8f4bc | ||
|
|
b0d2b44dad | ||
|
|
976b1a6bbd | ||
|
|
856eb446a5 | ||
|
|
992ba9d585 | ||
|
|
4b9d448b6c | ||
|
|
0ac1074a25 | ||
|
|
45f5ec15c2 | ||
|
|
a519e681dd | ||
|
|
fdb15e1296 | ||
|
|
d444b2c285 | ||
|
|
46ad1318e5 | ||
|
|
da332ed75d | ||
|
|
75d31f9d57 |
2
.gitattributes
vendored
@@ -12,5 +12,5 @@
|
||||
*.yaml text eol=lf
|
||||
|
||||
# Generated files
|
||||
src/types/comfyRegistryTypes.ts linguist-generated=true
|
||||
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true
|
||||
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true
|
||||
|
||||
67
.github/actions/setup-frontend/action.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Setup Frontend
|
||||
description: 'Setup ComfyUI frontend development environment'
|
||||
inputs:
|
||||
extra_server_params:
|
||||
description: 'Additional parameters to pass to ComfyUI server'
|
||||
required: false
|
||||
default: ''
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'comfyanonymous/ComfyUI'
|
||||
path: 'ComfyUI'
|
||||
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||
path: 'ComfyUI_frontend'
|
||||
|
||||
- name: Copy ComfyUI_devtools from frontend repo
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'ComfyUI_frontend/pnpm-lock.yaml'
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install Python requirements
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
pip install wait-for-it
|
||||
|
||||
- name: Build & Install ComfyUI_frontend
|
||||
shell: bash
|
||||
working-directory: ComfyUI_frontend
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
- name: Start ComfyUI server
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist ${{ inputs.extra_server_params }} &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
2
.github/workflows/backport.yaml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
186
.github/workflows/chromatic.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 'Chromatic'
|
||||
name: Storybook and Chromatic CI
|
||||
|
||||
# - [Automate Chromatic with GitHub Actions • Chromatic docs]( https://www.chromatic.com/docs/github-actions/ )
|
||||
|
||||
@@ -8,13 +8,100 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
chromatic-deployment:
|
||||
# Post starting comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for PRs from version-bump-* branches or manual triggers
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'version-bump-')
|
||||
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@v5
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"starting" \
|
||||
"$(date -u '+%m/%d/%Y, %I:%M:%S %p')"
|
||||
|
||||
# Build Storybook for all PRs (free Cloudflare deployment)
|
||||
storybook-build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cache
|
||||
storybook-static
|
||||
tsconfig.tsbuildinfo
|
||||
key: storybook-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.{ts,vue,js}', '*.config.*', '.storybook/**/*') }}
|
||||
restore-keys: |
|
||||
storybook-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
storybook-cache-${{ runner.os }}-
|
||||
storybook-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Storybook
|
||||
run: pnpm build-storybook
|
||||
|
||||
- name: Set job status
|
||||
id: job-status
|
||||
if: always()
|
||||
run: |
|
||||
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get workflow URL
|
||||
id: workflow-url
|
||||
if: always()
|
||||
run: |
|
||||
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload Storybook build
|
||||
if: success() && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-static
|
||||
path: storybook-static/
|
||||
retention-days: 7
|
||||
|
||||
# Chromatic deployment only for version-bump-* branches or manual triggers
|
||||
chromatic-deployment:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-'))
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
chromatic-build-url: ${{ steps.chromatic.outputs.buildUrl }}
|
||||
chromatic-storybook-url: ${{ steps.chromatic.outputs.storybookUrl }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # Required for Chromatic baseline
|
||||
|
||||
@@ -29,7 +116,6 @@ jobs:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -54,4 +140,92 @@ jobs:
|
||||
buildScriptName: build-storybook
|
||||
autoAcceptChanges: 'main' # Auto-accept changes on main branch
|
||||
exitOnceUploaded: true # Don't wait for UI tests to complete
|
||||
onlyChanged: true # Only capture changed stories
|
||||
|
||||
- name: Set job status
|
||||
id: job-status
|
||||
if: always()
|
||||
run: |
|
||||
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get workflow URL
|
||||
id: workflow-url
|
||||
if: always()
|
||||
run: |
|
||||
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [storybook-build]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download Storybook build
|
||||
if: needs.storybook-build.outputs.conclusion == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-static
|
||||
path: storybook-static
|
||||
|
||||
- name: Make deployment script executable
|
||||
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
|
||||
- name: Deploy Storybook and comment on PR
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_CONCLUSION: ${{ needs.storybook-build.outputs.conclusion }}
|
||||
WORKFLOW_URL: ${{ needs.storybook-build.outputs.workflow-url }}
|
||||
run: |
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"completed"
|
||||
|
||||
# Update comment with Chromatic URLs for version-bump branches
|
||||
update-comment-with-chromatic:
|
||||
needs: [chromatic-deployment, deploy-and-comment]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && startsWith(github.head_ref, 'version-bump-') && needs.chromatic-deployment.outputs.chromatic-build-url != ''
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Update comment with Chromatic URLs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';
|
||||
const storybookUrl = '${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }}';
|
||||
|
||||
// Find the existing Storybook comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ github.event.pull_request.number }}
|
||||
});
|
||||
|
||||
const storybookComment = comments.find(comment =>
|
||||
comment.body.includes('<!-- STORYBOOK_BUILD_STATUS -->')
|
||||
);
|
||||
|
||||
if (storybookComment && buildUrl && storybookUrl) {
|
||||
// Append Chromatic info to existing comment
|
||||
const updatedBody = storybookComment.body.replace(
|
||||
/---\n(.*)$/s,
|
||||
`---\n### 🎨 Chromatic Visual Tests\n- 📊 [View Chromatic Build](${buildUrl})\n- 📚 [View Chromatic Storybook](${storybookUrl})\n\n$1`
|
||||
);
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: storybookComment.id,
|
||||
body: updatedBody
|
||||
});
|
||||
}
|
||||
|
||||
2
.github/workflows/claude-pr-review.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/dev-release.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
version: ${{ steps.current_version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
||||
26
.github/workflows/devtools-python.yaml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Devtools Python Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'tools/devtools/**'
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'tools/devtools/**'
|
||||
|
||||
jobs:
|
||||
syntax:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Validate Python syntax
|
||||
run: python3 -m compileall -q tools/devtools
|
||||
6
.github/workflows/i18n-custom-nodes.yaml
vendored
@@ -22,13 +22,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: comfyanonymous/ComfyUI
|
||||
path: ComfyUI
|
||||
ref: master
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI_frontend
|
||||
path: ComfyUI_frontend
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
- name: Checkout custom node repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ inputs.owner }}/${{ inputs.repository }}
|
||||
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'
|
||||
|
||||
3
.github/workflows/i18n-node-defs.yaml
vendored
@@ -13,7 +13,8 @@ jobs:
|
||||
update-locales:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
|
||||
- name: Setup Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
3
.github/workflows/i18n.yaml
vendored
@@ -14,7 +14,8 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'version-bump-'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
|
||||
- name: Setup Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
|
||||
15
.github/workflows/json-validate.yaml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: Validate JSON
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
json-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Validate JSON syntax
|
||||
run: ./scripts/cicd/check-json.sh
|
||||
2
.github/workflows/lint-and-format.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
2
.github/workflows/pr-playwright-deploy.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
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
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Get PR Number
|
||||
id: pr
|
||||
|
||||
126
.github/workflows/pr-storybook-comment.yaml
vendored
@@ -1,126 +0,0 @@
|
||||
name: PR Storybook Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Chromatic']
|
||||
types: [requested, completed]
|
||||
|
||||
jobs:
|
||||
comment-storybook:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
&& github.event.workflow_run.event == 'pull_request'
|
||||
&& startsWith(github.event.workflow_run.head_branch, 'version-bump-')
|
||||
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: Log when no PR found
|
||||
if: steps.pr.outputs.result == 'null'
|
||||
run: |
|
||||
echo "⚠️ No open PR found for branch: ${{ github.event.workflow_run.head_branch }}"
|
||||
echo "Workflow run ID: ${{ github.event.workflow_run.id }}"
|
||||
echo "Repository: ${{ github.event.workflow_run.repository.full_name }}"
|
||||
echo "Event: ${{ github.event.workflow_run.event }}"
|
||||
|
||||
- name: Get workflow run details
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
id: workflow-run
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const run = await github.rest.actions.getWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
|
||||
return {
|
||||
conclusion: run.data.conclusion,
|
||||
html_url: run.data.html_url
|
||||
};
|
||||
|
||||
- name: Get completion time
|
||||
id: completion-time
|
||||
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Comment PR - Storybook Started
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
|
||||
with:
|
||||
issue-number: ${{ steps.pr.outputs.result }}
|
||||
body-includes: '<!-- STORYBOOK_BUILD_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!-- STORYBOOK_BUILD_STATUS -->
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
<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;' /> **Build is starting...**
|
||||
|
||||
⏰ Started at: ${{ steps.completion-time.outputs.time }} UTC
|
||||
|
||||
### 🚀 Building Storybook
|
||||
- 📦 Installing dependencies...
|
||||
- 🔧 Building Storybook components...
|
||||
- 🎨 Running Chromatic visual tests...
|
||||
|
||||
---
|
||||
⏱️ Please wait while the Storybook build is in progress...
|
||||
|
||||
- name: Comment PR - Storybook Complete
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
|
||||
with:
|
||||
issue-number: ${{ steps.pr.outputs.result }}
|
||||
body-includes: '<!-- STORYBOOK_BUILD_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!-- STORYBOOK_BUILD_STATUS -->
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
${{
|
||||
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && '✅'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && '⏭️'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && '🚫'
|
||||
|| '❌'
|
||||
}} **${{
|
||||
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && 'Build completed successfully!'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && 'Build skipped.'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && 'Build cancelled.'
|
||||
|| 'Build failed!'
|
||||
}}**
|
||||
|
||||
⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC
|
||||
|
||||
### 🔗 Links
|
||||
- [📊 View Workflow Run](${{ fromJSON(steps.workflow-run.outputs.result).html_url }})
|
||||
|
||||
---
|
||||
${{
|
||||
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && '🎉 Your Storybook is ready for review!'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && 'ℹ️ Chromatic was skipped for this PR.'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && 'ℹ️ The Chromatic run was cancelled.'
|
||||
|| '⚠️ Please check the workflow logs for error details.'
|
||||
}}
|
||||
90
.github/workflows/pr-storybook-deploy.yaml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
name: PR Storybook Deploy (Forks)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Storybook and Chromatic CI']
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
|
||||
|
||||
jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository != null &&
|
||||
github.event.workflow_run.repository != null &&
|
||||
github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
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@v5
|
||||
|
||||
- name: Get PR Number
|
||||
id: pr
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
|
||||
return pr.number;
|
||||
|
||||
- name: Handle Storybook Start
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting" \
|
||||
"$(date -u '${{ env.DATE_FORMAT }}')"
|
||||
|
||||
- name: Download and Deploy Storybook
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: storybook-static
|
||||
path: storybook-static
|
||||
|
||||
- name: Handle Storybook Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
|
||||
WORKFLOW_URL: ${{ github.event.workflow_run.html_url }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
5
.github/workflows/test-browser-exp.yaml
vendored
@@ -10,7 +10,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'New Browser Test Expectations'
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
|
||||
- name: Checkout workflow repo
|
||||
uses: actions/checkout@v5
|
||||
- name: Setup Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
||||
10
.github/workflows/test-ui.yaml
vendored
@@ -15,14 +15,14 @@ jobs:
|
||||
playwright-version: ${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: 'comfyanonymous/ComfyUI'
|
||||
path: 'ComfyUI'
|
||||
ref: master
|
||||
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||
path: 'ComfyUI_frontend'
|
||||
@@ -250,7 +250,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||
path: 'ComfyUI_frontend'
|
||||
@@ -306,7 +306,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Get start time
|
||||
id: start-time
|
||||
@@ -333,7 +333,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download all playwright reports
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
2
.github/workflows/update-electron-types.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
4
.github/workflows/update-manager-types.yaml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
comfyui-manager-repo-${{ runner.os }}-
|
||||
|
||||
- name: Checkout ComfyUI-Manager repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI-Manager
|
||||
path: ComfyUI-Manager
|
||||
|
||||
17
.github/workflows/update-registry-types.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
comfy-api-repo-${{ runner.os }}-
|
||||
|
||||
- name: Checkout comfy-api repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: Comfy-Org/comfy-api
|
||||
path: comfy-api
|
||||
@@ -68,17 +68,18 @@ jobs:
|
||||
- name: Generate API types
|
||||
run: |
|
||||
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
|
||||
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts
|
||||
mkdir -p ./packages/registry-types/src
|
||||
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts
|
||||
|
||||
- name: Validate generated types
|
||||
run: |
|
||||
if [ ! -f ./src/types/comfyRegistryTypes.ts ]; then
|
||||
if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
|
||||
echo "Error: Types file was not generated."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if file is not empty
|
||||
if [ ! -s ./src/types/comfyRegistryTypes.ts ]; then
|
||||
if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
|
||||
echo "Error: Generated types file is empty."
|
||||
exit 1
|
||||
fi
|
||||
@@ -86,12 +87,12 @@ jobs:
|
||||
- name: Lint generated types
|
||||
run: |
|
||||
echo "Linting generated Comfy Registry API types..."
|
||||
pnpm lint:fix:no-cache -- ./src/types/comfyRegistryTypes.ts
|
||||
pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts
|
||||
|
||||
- name: Check for changes
|
||||
id: check-changes
|
||||
run: |
|
||||
if [[ -z $(git status --porcelain ./src/types/comfyRegistryTypes.ts) ]]; then
|
||||
if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then
|
||||
echo "No changes to Comfy Registry API types detected."
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
@@ -121,4 +122,4 @@ jobs:
|
||||
labels: CNR
|
||||
delete-branch: true
|
||||
add-paths: |
|
||||
src/types/comfyRegistryTypes.ts
|
||||
packages/registry-types/src/comfyRegistryTypes.ts
|
||||
|
||||
2
.github/workflows/version-bump.yaml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
2
.github/workflows/vitest.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
src/types/comfyRegistryTypes.ts
|
||||
src/types/generatedManagerTypes.ts
|
||||
packages/registry-types/src/comfyRegistryTypes.ts
|
||||
src/types/generatedManagerTypes.ts
|
||||
|
||||
@@ -31,10 +31,9 @@
|
||||
- Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Commits: Prefer Conventional Commits (e.g., `feat(ui): add sidebar`), `refactor(litegraph): …`. Use `[skip ci]` for locale-only updates when appropriate.
|
||||
- PRs: Include clear description, linked issues (`Fixes #123`), and screenshots/GIFs for UI changes. Add/adjust tests and i18n strings when applicable.
|
||||
- Commits: Use `[skip ci]` for locale-only updates when appropriate.
|
||||
- PRs: Include clear description, linked issues (`- Fixes #123`), and screenshots/GIFs for UI changes.
|
||||
- Quality gates: `pnpm lint`, `pnpm typecheck`, and relevant tests must pass. Keep PRs focused and small.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Secrets: Use `.env` (see `.env_example`); do not commit secrets.
|
||||
- Backend: Dev server expects ComfyUI backend at `localhost:8188` by default; configure via `.env`.
|
||||
|
||||
@@ -84,7 +84,7 @@ UI mode features:
|
||||
- **Console/Network Tabs**: View logs and API calls at each step
|
||||
- **Attachments Tab**: View all snapshots with expected and actual images
|
||||
|
||||

|
||||

|
||||
|
||||
For CI or headless testing:
|
||||
|
||||
|
||||
221
browser_tests/assets/widgets/all_load_widgets.json
Normal file
@@ -0,0 +1,221 @@
|
||||
{
|
||||
"id": "e74f5af9-b886-4a21-abbf-ed535d12e2fb",
|
||||
"revision": 0,
|
||||
"last_node_id": 8,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadAudio",
|
||||
"pos": [
|
||||
41.52964782714844,
|
||||
16.930862426757812
|
||||
],
|
||||
"size": [
|
||||
444,
|
||||
125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "AUDIO",
|
||||
"type": "AUDIO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadAudio"
|
||||
},
|
||||
"widgets_values": [
|
||||
null,
|
||||
null,
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadVideo",
|
||||
"pos": [
|
||||
502.28570556640625,
|
||||
16.857147216796875
|
||||
],
|
||||
"size": [
|
||||
444,
|
||||
525
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VIDEO",
|
||||
"type": "VIDEO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
null,
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "DevToolsLoadAnimatedImageTest",
|
||||
"pos": [
|
||||
41.71427917480469,
|
||||
188.0000457763672
|
||||
],
|
||||
"size": [
|
||||
444,
|
||||
553
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "DevToolsLoadAnimatedImageTest"
|
||||
},
|
||||
"widgets_values": [
|
||||
null,
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
958.285888671875,
|
||||
16.57145118713379
|
||||
],
|
||||
"size": [
|
||||
444,
|
||||
553
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
null,
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "LoadImageMask",
|
||||
"pos": [
|
||||
503.4285888671875,
|
||||
588
|
||||
],
|
||||
"size": [
|
||||
444,
|
||||
563
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImageMask"
|
||||
},
|
||||
"widgets_values": [
|
||||
null,
|
||||
"alpha",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "LoadImageOutput",
|
||||
"pos": [
|
||||
965.1429443359375,
|
||||
612
|
||||
],
|
||||
"size": [
|
||||
444,
|
||||
553
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImageOutput"
|
||||
},
|
||||
"widgets_values": [
|
||||
null,
|
||||
false,
|
||||
"refresh",
|
||||
"image"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.28.3"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -210,28 +210,6 @@ test.describe('Templates', () => {
|
||||
await expect(nav).toBeVisible() // Nav should be visible at tablet size
|
||||
})
|
||||
|
||||
test('hover effects work on template cards', async ({ comfyPage }) => {
|
||||
// Open templates dialog
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Get a template card using data-testid
|
||||
const firstCard = comfyPage.page
|
||||
.locator('[data-testid^="template-workflow-"]')
|
||||
.first()
|
||||
await expect(firstCard).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Check initial state - card should have transition classes
|
||||
// Take snapshot before hover
|
||||
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
|
||||
|
||||
// Hover over the card
|
||||
await firstCard.hover()
|
||||
|
||||
// Take snapshot after hover to verify hover effect
|
||||
await expect(firstCard).toHaveScreenshot('template-card-after-hover.png')
|
||||
})
|
||||
|
||||
test('template cards descriptions adjust height dynamically', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -85,6 +85,7 @@ test.describe('Version Mismatch Warnings', () => {
|
||||
test('should persist dismissed state across sessions', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30_000)
|
||||
// Mock system_stats route to indicate that the installed version is always ahead of the required version
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
await route.fulfill({
|
||||
|
||||
33
browser_tests/tests/vueNodes/groups/groups.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const CREATE_GROUP_HOTKEY = 'Control+g'
|
||||
|
||||
test.describe('Vue Node Groups', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow creating groups with hotkey', async ({ comfyPage }) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-groups-create-group.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should allow fitting group to contents', async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('groups/oversized_group')
|
||||
await comfyPage.ctrlA()
|
||||
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-groups-fit-to-contents.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 47 KiB |
18
browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes Canvas Pan', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('@mobile Can pan with touch', async ({ comfyPage }) => {
|
||||
await comfyPage.panWithTouch({ x: 64, y: 64 }, { x: 256, y: 256 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-paned-with-touch.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes Zoom', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should not capture drag while zooming with ctrl+shift+drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const nodeBoundingBox = await checkpointNode.boundingBox()
|
||||
if (!nodeBoundingBox) throw new Error('Node bounding box not available')
|
||||
|
||||
const nodeMidpointX = nodeBoundingBox.x + nodeBoundingBox.width / 2
|
||||
const nodeMidpointY = nodeBoundingBox.y + nodeBoundingBox.height / 2
|
||||
|
||||
// Start the Ctrl+Shift drag-to-zoom on the canvas and continue dragging over
|
||||
// the node. The node should not capture the drag while drag-zooming.
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: 200, y: 300 },
|
||||
{ x: nodeMidpointX, y: nodeMidpointY }
|
||||
)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,696 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { getMiddlePoint } from '../../../../fixtures/utils/litegraphUtils'
|
||||
import { fitToViewInstant } from '../../../../helpers/fitToView'
|
||||
|
||||
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('Slot bounding box not available')
|
||||
return {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
async function getInputLinkDetails(
|
||||
page: Page,
|
||||
nodeId: NodeId,
|
||||
slotIndex: number
|
||||
) {
|
||||
return await page.evaluate(
|
||||
([targetNodeId, targetSlot]) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return null
|
||||
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
if (!node) return null
|
||||
|
||||
const input = node.inputs?.[targetSlot]
|
||||
if (!input) return null
|
||||
|
||||
const linkId = input.link
|
||||
if (linkId == null) return null
|
||||
|
||||
const link = graph.getLink?.(linkId)
|
||||
if (!link) return null
|
||||
|
||||
return {
|
||||
id: link.id,
|
||||
originId: link.origin_id,
|
||||
originSlot:
|
||||
typeof link.origin_slot === 'string'
|
||||
? Number.parseInt(link.origin_slot, 10)
|
||||
: link.origin_slot,
|
||||
targetId: link.target_id,
|
||||
targetSlot:
|
||||
typeof link.target_slot === 'string'
|
||||
? Number.parseInt(link.target_slot, 10)
|
||||
: link.target_slot,
|
||||
parentId: link.parentId ?? null
|
||||
}
|
||||
},
|
||||
[nodeId, slotIndex] as const
|
||||
)
|
||||
}
|
||||
|
||||
// Test helpers to reduce repetition across cases
|
||||
function slotLocator(
|
||||
page: Page,
|
||||
nodeId: NodeId,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) {
|
||||
const key = getSlotKey(String(nodeId), slotIndex, isInput)
|
||||
return page.locator(`[data-slot-key="${key}"]`)
|
||||
}
|
||||
|
||||
async function expectVisibleAll(...locators: Locator[]) {
|
||||
await Promise.all(locators.map((l) => expect(l).toBeVisible()))
|
||||
}
|
||||
|
||||
async function getSlotCenter(
|
||||
page: Page,
|
||||
nodeId: NodeId,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) {
|
||||
const locator = slotLocator(page, nodeId, slotIndex, isInput)
|
||||
await expect(locator).toBeVisible()
|
||||
return await getCenter(locator)
|
||||
}
|
||||
|
||||
async function connectSlots(
|
||||
page: Page,
|
||||
from: { nodeId: NodeId; index: number },
|
||||
to: { nodeId: NodeId; index: number },
|
||||
nextFrame: () => Promise<void>
|
||||
) {
|
||||
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
|
||||
const toLoc = slotLocator(page, to.nodeId, to.index, true)
|
||||
await expectVisibleAll(fromLoc, toLoc)
|
||||
await fromLoc.dragTo(toLoc)
|
||||
await nextFrame()
|
||||
}
|
||||
|
||||
test.describe('Vue Node Link Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('vueNodes/simple-triple')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await fitToViewInstant(comfyPage)
|
||||
})
|
||||
|
||||
test('should show a link dragging out from a slot when dragging on a slot', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const slot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
|
||||
await expect(slot).toBeVisible()
|
||||
|
||||
const start = await getCenter(slot)
|
||||
|
||||
// Arbitrary value
|
||||
const dragTarget = {
|
||||
x: start.x + 180,
|
||||
y: start.y - 140
|
||||
}
|
||||
|
||||
await comfyMouse.move(start)
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
try {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-dragging-link.png'
|
||||
)
|
||||
} finally {
|
||||
await comfyMouse.drop()
|
||||
}
|
||||
})
|
||||
|
||||
test('should create a link when dropping on a compatible slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
expect(samplerNode && vaeNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: samplerNode.id, index: 0 },
|
||||
{ nodeId: vaeNode.id, index: 0 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
expect(await vaeInput.getLinkCount()).toBe(1)
|
||||
|
||||
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails).toMatchObject({
|
||||
originId: samplerNode.id,
|
||||
originSlot: 0,
|
||||
targetId: vaeNode.id,
|
||||
targetSlot: 0
|
||||
})
|
||||
})
|
||||
|
||||
test('should not create a link when slot types are incompatible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
expect(samplerNode && clipNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const clipInput = await clipNode.getInput(0)
|
||||
|
||||
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
|
||||
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
expect(await clipInput.getLinkCount()).toBe(0)
|
||||
|
||||
const graphLinkDetails = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
clipNode.id,
|
||||
0
|
||||
)
|
||||
expect(graphLinkDetails).toBeNull()
|
||||
})
|
||||
|
||||
test('should not create a link when dropping onto a slot on the same node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const samplerInput = await samplerNode.getInput(3)
|
||||
|
||||
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false)
|
||||
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
expect(await samplerInput.getLinkCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('should reuse the existing origin when dragging an input link', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
expect(samplerNode && vaeNode).toBeTruthy()
|
||||
const samplerOutputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const vaeInputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0,
|
||||
true
|
||||
)
|
||||
|
||||
await comfyMouse.move(samplerOutputCenter)
|
||||
await comfyMouse.drag(vaeInputCenter)
|
||||
await comfyMouse.drop()
|
||||
|
||||
const dragTarget = {
|
||||
x: vaeInputCenter.x + 160,
|
||||
y: vaeInputCenter.y - 100
|
||||
}
|
||||
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-input-drag-reuses-origin.png'
|
||||
)
|
||||
await comfyMouse.drop()
|
||||
})
|
||||
|
||||
test('ctrl+alt drag from an input starts a fresh link', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
expect(samplerNode && vaeNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
const samplerOutputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const vaeInputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0,
|
||||
true
|
||||
)
|
||||
|
||||
await comfyMouse.move(samplerOutputCenter)
|
||||
await comfyMouse.drag(vaeInputCenter)
|
||||
await comfyMouse.drop()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dragTarget = {
|
||||
x: vaeInputCenter.x + 140,
|
||||
y: vaeInputCenter.y - 110
|
||||
}
|
||||
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
|
||||
try {
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-input-drag-ctrl-alt.png'
|
||||
)
|
||||
} finally {
|
||||
await comfyMouse.drop().catch(() => {})
|
||||
await comfyPage.page.keyboard.up('Alt').catch(() => {})
|
||||
await comfyPage.page.keyboard.up('Control').catch(() => {})
|
||||
}
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Tcehnically intended to disconnect existing as well
|
||||
expect(await vaeInput.getLinkCount()).toBe(0)
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('dropping an input link back on its slot restores the original connection', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
expect(samplerNode && vaeNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
const samplerOutputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const vaeInputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0,
|
||||
true
|
||||
)
|
||||
|
||||
await comfyMouse.move(samplerOutputCenter)
|
||||
try {
|
||||
await comfyMouse.drag(vaeInputCenter)
|
||||
} finally {
|
||||
await comfyMouse.drop()
|
||||
}
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalLink = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0
|
||||
)
|
||||
expect(originalLink).not.toBeNull()
|
||||
|
||||
const dragTarget = {
|
||||
x: vaeInputCenter.x + 150,
|
||||
y: vaeInputCenter.y - 100
|
||||
}
|
||||
|
||||
// To prevent needing a screenshot expectation for whether the link's off
|
||||
const vaeInputLocator = slotLocator(comfyPage.page, vaeNode.id, 0, true)
|
||||
const inputBox = await vaeInputLocator.boundingBox()
|
||||
if (!inputBox) throw new Error('Input slot bounding box not available')
|
||||
const isOutsideX =
|
||||
dragTarget.x < inputBox.x || dragTarget.x > inputBox.x + inputBox.width
|
||||
const isOutsideY =
|
||||
dragTarget.y < inputBox.y || dragTarget.y > inputBox.y + inputBox.height
|
||||
expect(isOutsideX || isOutsideY).toBe(true)
|
||||
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyMouse.drop()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const restoredLink = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0
|
||||
)
|
||||
|
||||
expect(restoredLink).not.toBeNull()
|
||||
if (!restoredLink || !originalLink) {
|
||||
throw new Error('Expected both original and restored links to exist')
|
||||
}
|
||||
expect(restoredLink).toMatchObject({
|
||||
originId: originalLink.originId,
|
||||
originSlot: originalLink.originSlot,
|
||||
targetId: originalLink.targetId,
|
||||
targetSlot: originalLink.targetSlot,
|
||||
parentId: originalLink.parentId
|
||||
})
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
expect(await vaeInput.getLinkCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('rerouted input drag preview remains anchored to reroute', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: samplerNode.id, index: 0 },
|
||||
{ nodeId: vaeNode.id, index: 0 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
|
||||
const outputPosition = await samplerOutput.getPosition()
|
||||
const inputPosition = await vaeInput.getPosition()
|
||||
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
|
||||
|
||||
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
|
||||
// This avoids relying on an exact path hit-test position.
|
||||
await comfyPage.page.evaluate(
|
||||
([targetNodeId, targetSlot, clientPoint]) => {
|
||||
const app = (window as any)['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) throw new Error('Graph not available')
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
if (!node) throw new Error('Target node not found')
|
||||
const input = node.inputs?.[targetSlot]
|
||||
if (!input) throw new Error('Target input slot not found')
|
||||
|
||||
const linkId = input.link
|
||||
if (linkId == null) throw new Error('Expected existing link on input')
|
||||
const link = graph.getLink(linkId)
|
||||
if (!link) throw new Error('Link not found')
|
||||
|
||||
// Convert the client/canvas pixel coordinates to graph space
|
||||
const pos = app.canvas.ds.convertCanvasToOffset([
|
||||
clientPoint.x,
|
||||
clientPoint.y
|
||||
])
|
||||
graph.createReroute(pos, link)
|
||||
},
|
||||
[vaeNode.id, 0, reroutePoint] as const
|
||||
)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const vaeInputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
vaeNode.id,
|
||||
0,
|
||||
true
|
||||
)
|
||||
const dragTarget = {
|
||||
x: vaeInputCenter.x + 160,
|
||||
y: vaeInputCenter.y - 120
|
||||
}
|
||||
|
||||
let dropped = false
|
||||
try {
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-reroute-input-drag.png'
|
||||
)
|
||||
await comfyMouse.move(vaeInputCenter)
|
||||
await comfyMouse.drop()
|
||||
dropped = true
|
||||
} finally {
|
||||
if (!dropped) {
|
||||
await comfyMouse.drop().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails?.originId).toBe(samplerNode.id)
|
||||
expect(linkDetails?.parentId).not.toBeNull()
|
||||
})
|
||||
|
||||
test('rerouted output shift-drag preview remains anchored to reroute', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
|
||||
expect(samplerNode && vaeNode).toBeTruthy()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: samplerNode.id, index: 0 },
|
||||
{ nodeId: vaeNode.id, index: 0 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
|
||||
const outputPosition = await samplerOutput.getPosition()
|
||||
const inputPosition = await vaeInput.getPosition()
|
||||
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
|
||||
|
||||
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
|
||||
// This avoids relying on an exact path hit-test position.
|
||||
await comfyPage.page.evaluate(
|
||||
([targetNodeId, targetSlot, clientPoint]) => {
|
||||
const app = (window as any)['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) throw new Error('Graph not available')
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
if (!node) throw new Error('Target node not found')
|
||||
const input = node.inputs?.[targetSlot]
|
||||
if (!input) throw new Error('Target input slot not found')
|
||||
|
||||
const linkId = input.link
|
||||
if (linkId == null) throw new Error('Expected existing link on input')
|
||||
const link = graph.getLink(linkId)
|
||||
if (!link) throw new Error('Link not found')
|
||||
|
||||
// Convert the client/canvas pixel coordinates to graph space
|
||||
const pos = app.canvas.ds.convertCanvasToOffset([
|
||||
clientPoint.x,
|
||||
clientPoint.y
|
||||
])
|
||||
graph.createReroute(pos, link)
|
||||
},
|
||||
[vaeNode.id, 0, reroutePoint] as const
|
||||
)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const dragTarget = {
|
||||
x: outputCenter.x + 150,
|
||||
y: outputCenter.y - 140
|
||||
}
|
||||
|
||||
let dropPending = false
|
||||
let shiftHeld = false
|
||||
try {
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
shiftHeld = true
|
||||
dropPending = true
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-reroute-output-shift-drag.png'
|
||||
)
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyMouse.drop()
|
||||
dropPending = false
|
||||
} finally {
|
||||
if (dropPending) await comfyMouse.drop().catch(() => {})
|
||||
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails?.originId).toBe(samplerNode.id)
|
||||
expect(linkDetails?.parentId).not.toBeNull()
|
||||
})
|
||||
|
||||
test('dragging input to input drags existing link', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(clipNode && samplerNode).toBeTruthy()
|
||||
|
||||
// Step 1: Connect CLIP's only output (index 0) to KSampler's second input (index 1)
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: clipNode.id, index: 0 },
|
||||
{ nodeId: samplerNode.id, index: 1 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
|
||||
// Verify initial link exists between CLIP -> KSampler input[1]
|
||||
const initialLink = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
1
|
||||
)
|
||||
expect(initialLink).not.toBeNull()
|
||||
expect(initialLink).toMatchObject({
|
||||
originId: clipNode.id,
|
||||
targetId: samplerNode.id,
|
||||
targetSlot: 1
|
||||
})
|
||||
|
||||
// Step 2: Drag from KSampler's second input to its third input (index 2)
|
||||
const input2Center = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
1,
|
||||
true
|
||||
)
|
||||
const input3Center = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
2,
|
||||
true
|
||||
)
|
||||
|
||||
await comfyMouse.move(input2Center)
|
||||
await comfyMouse.drag(input3Center)
|
||||
await comfyMouse.drop()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Expect old link removed from input[1]
|
||||
const afterSecondInput = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
1
|
||||
)
|
||||
expect(afterSecondInput).toBeNull()
|
||||
|
||||
// Expect new link exists at input[2] from CLIP
|
||||
const afterThirdInput = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
2
|
||||
)
|
||||
expect(afterThirdInput).not.toBeNull()
|
||||
expect(afterThirdInput).toMatchObject({
|
||||
originId: clipNode.id,
|
||||
targetId: samplerNode.id,
|
||||
targetSlot: 2
|
||||
})
|
||||
})
|
||||
|
||||
test('shift-dragging an output with multiple links should drag all links', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(clipNode && samplerNode).toBeTruthy()
|
||||
|
||||
const clipOutput = await clipNode.getOutput(0)
|
||||
|
||||
// Connect output[0] -> inputs[1] and [2]
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: clipNode.id, index: 0 },
|
||||
{ nodeId: samplerNode.id, index: 1 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: clipNode.id, index: 0 },
|
||||
{ nodeId: samplerNode.id, index: 2 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
|
||||
expect(await clipOutput.getLinkCount()).toBe(2)
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
clipNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const dragTarget = {
|
||||
x: outputCenter.x + 40,
|
||||
y: outputCenter.y - 140
|
||||
}
|
||||
|
||||
let dropPending = false
|
||||
let shiftHeld = false
|
||||
try {
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
shiftHeld = true
|
||||
await comfyMouse.drag(dragTarget)
|
||||
dropPending = true
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-shift-output-multi-link.png'
|
||||
)
|
||||
} finally {
|
||||
if (dropPending) await comfyMouse.drop().catch(() => {})
|
||||
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('Vue Nodes Renaming', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('should display node title', async ({ comfyPage }) => {
|
||||
// Get the KSampler node from the default workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
|
||||
const title = await vueNode.getTitle()
|
||||
expect(title).toBe('KSampler')
|
||||
|
||||
// Verify title is visible in the header
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should allow title renaming by double clicking on the node header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
|
||||
// Test renaming with Enter
|
||||
await vueNode.setTitle('My Custom Sampler')
|
||||
const newTitle = await vueNode.getTitle()
|
||||
expect(newTitle).toBe('My Custom Sampler')
|
||||
|
||||
// Verify the title is displayed
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('My Custom Sampler')
|
||||
|
||||
// Test cancel with Escape
|
||||
const titleElement = await vueNode.getTitleElement()
|
||||
await titleElement.dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Type a different value but cancel
|
||||
const input = (await vueNode.getHeader()).locator(
|
||||
'[data-testid="node-title-input"]'
|
||||
)
|
||||
await input.fill('This Should Be Cancelled')
|
||||
await input.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Title should remain as the previously saved value
|
||||
const titleAfterCancel = await vueNode.getTitle()
|
||||
expect(titleAfterCancel).toBe('My Custom Sampler')
|
||||
})
|
||||
|
||||
test('Double click node body does not trigger edit', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const nodeBbox = await loadCheckpointNode.boundingBox()
|
||||
if (!nodeBbox) throw new Error('Node not found')
|
||||
await loadCheckpointNode.dblclick()
|
||||
|
||||
const editingTitleInput = comfyPage.page.getByTestId('node-title-input')
|
||||
await expect(editingTitleInput).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -15,7 +15,8 @@ test.describe('Vue Node Selection', () => {
|
||||
|
||||
const modifiers = [
|
||||
{ key: 'Control', name: 'ctrl' },
|
||||
{ key: 'Shift', name: 'shift' }
|
||||
{ key: 'Shift', name: 'shift' },
|
||||
{ key: 'Meta', name: 'meta' }
|
||||
] as const
|
||||
|
||||
for (const { key: modifier, name } of modifiers) {
|
||||
@@ -1,221 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../../helpers/fitToView'
|
||||
|
||||
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('Slot bounding box not available')
|
||||
return {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Vue Node Link Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('vueNodes/simple-triple')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await fitToViewInstant(comfyPage)
|
||||
})
|
||||
|
||||
test('should show a link dragging out from a slot when dragging on a slot', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
|
||||
const samplerNode = samplerNodes[0]
|
||||
const outputSlot = await samplerNode.getOutput(0)
|
||||
await outputSlot.removeLinks()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const slotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`)
|
||||
await expect(slotLocator).toBeVisible()
|
||||
|
||||
const start = await getCenter(slotLocator)
|
||||
const canvasBox = await comfyPage.canvas.boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas bounding box not available')
|
||||
|
||||
// Arbitrary value
|
||||
const dragTarget = {
|
||||
x: start.x + 180,
|
||||
y: start.y - 140
|
||||
}
|
||||
|
||||
await comfyMouse.move(start)
|
||||
await comfyMouse.drag(dragTarget)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
try {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-dragging-link.png'
|
||||
)
|
||||
} finally {
|
||||
await comfyMouse.drop()
|
||||
}
|
||||
})
|
||||
|
||||
test('should create a link when dropping on a compatible slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
|
||||
expect(vaeNodes.length).toBeGreaterThan(0)
|
||||
const vaeNode = vaeNodes[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const vaeInput = await vaeNode.getInput(0)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
expect(await vaeInput.getLinkCount()).toBe(1)
|
||||
|
||||
const linkDetails = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return null
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return null
|
||||
|
||||
const linkId = source.outputs[0]?.links?.[0]
|
||||
if (linkId == null) return null
|
||||
|
||||
const link = graph.links[linkId]
|
||||
if (!link) return null
|
||||
|
||||
return {
|
||||
originId: link.origin_id,
|
||||
originSlot: link.origin_slot,
|
||||
targetId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
}
|
||||
}, samplerNode.id)
|
||||
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails).toMatchObject({
|
||||
originId: samplerNode.id,
|
||||
originSlot: 0,
|
||||
targetId: vaeNode.id,
|
||||
targetSlot: 0
|
||||
})
|
||||
})
|
||||
|
||||
test('should not create a link when slot types are incompatible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(clipNodes.length).toBeGreaterThan(0)
|
||||
const clipNode = clipNodes[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const clipInput = await clipNode.getInput(0)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(clipNode.id), 0, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
expect(await clipInput.getLinkCount()).toBe(0)
|
||||
|
||||
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return 0
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return 0
|
||||
|
||||
return source.outputs[0]?.links?.length ?? 0
|
||||
}, samplerNode.id)
|
||||
|
||||
expect(graphLinkCount).toBe(0)
|
||||
})
|
||||
|
||||
test('should not create a link when dropping onto a slot on the same node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(samplerNodes.length).toBeGreaterThan(0)
|
||||
const samplerNode = samplerNodes[0]
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
const samplerInput = await samplerNode.getInput(3)
|
||||
|
||||
const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
|
||||
const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true)
|
||||
|
||||
const outputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${outputSlotKey}"]`
|
||||
)
|
||||
const inputSlot = comfyPage.page.locator(
|
||||
`[data-slot-key="${inputSlotKey}"]`
|
||||
)
|
||||
|
||||
await expect(outputSlot).toBeVisible()
|
||||
await expect(inputSlot).toBeVisible()
|
||||
|
||||
await outputSlot.dragTo(inputSlot)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await samplerOutput.getLinkCount()).toBe(0)
|
||||
expect(await samplerInput.getLinkCount()).toBe(0)
|
||||
|
||||
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) return 0
|
||||
|
||||
const source = graph.getNodeById(sourceId)
|
||||
if (!source) return 0
|
||||
|
||||
return source.outputs[0]?.links?.length ?? 0
|
||||
}, samplerNode.id)
|
||||
|
||||
expect(graphLinkCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 106 KiB |
@@ -1,67 +1,20 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../fixtures/ComfyPage'
|
||||
import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures'
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('NodeHeader', () => {
|
||||
test.describe('Vue Node Collapse', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
await comfyPage.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('displays node title', async ({ comfyPage }) => {
|
||||
// Get the KSampler node from the default workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
|
||||
const title = await vueNode.getTitle()
|
||||
expect(title).toBe('KSampler')
|
||||
|
||||
// Verify title is visible in the header
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('allows title renaming', async ({ comfyPage }) => {
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
|
||||
// Test renaming with Enter
|
||||
await vueNode.setTitle('My Custom Sampler')
|
||||
const newTitle = await vueNode.getTitle()
|
||||
expect(newTitle).toBe('My Custom Sampler')
|
||||
|
||||
// Verify the title is displayed
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('My Custom Sampler')
|
||||
|
||||
// Test cancel with Escape
|
||||
const titleElement = await vueNode.getTitleElement()
|
||||
await titleElement.dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Type a different value but cancel
|
||||
const input = (await vueNode.getHeader()).locator(
|
||||
'[data-testid="node-title-input"]'
|
||||
)
|
||||
await input.fill('This Should Be Cancelled')
|
||||
await input.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Title should remain as the previously saved value
|
||||
const titleAfterCancel = await vueNode.getTitle()
|
||||
expect(titleAfterCancel).toBe('My Custom Sampler')
|
||||
})
|
||||
|
||||
test('handles node collapsing', async ({ comfyPage }) => {
|
||||
test('should allow collapsing node with collapse icon', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get the KSampler node from the default workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
@@ -90,7 +43,7 @@ test.describe('NodeHeader', () => {
|
||||
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
|
||||
})
|
||||
|
||||
test('shows collapse/expand icon state', async ({ comfyPage }) => {
|
||||
test('should show collapse/expand icon state', async ({ comfyPage }) => {
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
@@ -110,7 +63,9 @@ test.describe('NodeHeader', () => {
|
||||
expect(iconClass).toContain('pi-chevron-down')
|
||||
})
|
||||
|
||||
test('preserves title when collapsing/expanding', async ({ comfyPage }) => {
|
||||
test('should preserve title when collapsing/expanding', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
49
browser_tests/tests/vueNodes/nodeStates/colors.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Custom Colors', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('displays color picker button and allows color selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'Load Checkpoint'
|
||||
})
|
||||
await loadCheckpointNode.getByText('Load Checkpoint').click()
|
||||
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container')
|
||||
.locator('i[data-testid="blue"]')
|
||||
.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-custom-color-blue.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should load node colors from workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/every_node_color')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-custom-colors-dark-all-colors.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should show brightened node colors on light theme', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.loadWorkflow('nodes/every_node_color')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-custom-colors-light-all-colors.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 92 KiB |
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 108 KiB |
85
browser_tests/tests/vueNodes/nodeStates/pin.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const PIN_HOTKEY = 'p'
|
||||
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
|
||||
|
||||
test.describe('Vue Node Pin', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow toggling pin on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const pinIndicator = checkpointNode.locator(PIN_INDICATOR)
|
||||
|
||||
await expect(pinIndicator).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
await expect(pinIndicator).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should allow toggling pin on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
const pinIndicator1 = checkpointNode.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator1).toBeVisible()
|
||||
const pinIndicator2 = ksamplerNode.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator2).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
await expect(pinIndicator1).not.toBeVisible()
|
||||
await expect(pinIndicator2).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
// Try to drag the node
|
||||
const headerPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPos) throw new Error('Failed to get header position')
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
|
||||
// Verify the node is not dragged (same position before and after click-and-drag)
|
||||
const headerPosAfterDrag = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPosAfterDrag)
|
||||
throw new Error('Failed to get header position after drag')
|
||||
expect(headerPosAfterDrag).toEqual(headerPos)
|
||||
|
||||
// Unpin the node with the hotkey
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
// Try to drag the node again
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
|
||||
// Verify the node is dragged
|
||||
const headerPosAfterDrag2 = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPosAfterDrag2)
|
||||
throw new Error('Failed to get header position after drag')
|
||||
expect(headerPosAfterDrag2).not.toEqual(headerPos)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Upload Widgets', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('widgets/all_load_widgets')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-upload-widgets.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
type ComfyPage,
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Multiline String Widget', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
const getFirstClipNode = (comfyPage: ComfyPage) =>
|
||||
comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first()
|
||||
|
||||
const getFirstMultilineStringWidget = (comfyPage: ComfyPage) =>
|
||||
getFirstClipNode(comfyPage).getByRole('textbox', { name: 'text' })
|
||||
|
||||
test('should allow entering text', async ({ comfyPage }) => {
|
||||
const textarea = getFirstMultilineStringWidget(comfyPage)
|
||||
await textarea.fill('Hello World')
|
||||
await expect(textarea).toHaveValue('Hello World')
|
||||
await textarea.fill('Hello World 2')
|
||||
await expect(textarea).toHaveValue('Hello World 2')
|
||||
})
|
||||
|
||||
test('should support entering multiline content', async ({ comfyPage }) => {
|
||||
const textarea = getFirstMultilineStringWidget(comfyPage)
|
||||
|
||||
const multilineValue = ['Line 1', 'Line 2', 'Line 3'].join('\n')
|
||||
|
||||
await textarea.fill(multilineValue)
|
||||
await expect(textarea).toHaveValue(multilineValue)
|
||||
})
|
||||
|
||||
test('should retain value after focus changes', async ({ comfyPage }) => {
|
||||
const textarea = getFirstMultilineStringWidget(comfyPage)
|
||||
|
||||
await textarea.fill('Keep me around')
|
||||
|
||||
// Click another node
|
||||
const loadCheckpointNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await loadCheckpointNode.click()
|
||||
await getFirstClipNode(comfyPage).click()
|
||||
|
||||
await expect(textarea).toHaveValue('Keep me around')
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ export default defineConfig([
|
||||
'src/scripts/*',
|
||||
'src/extensions/core/*',
|
||||
'src/types/vue-shim.d.ts',
|
||||
'src/types/comfyRegistryTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
'src/types/generatedManagerTypes.ts',
|
||||
'**/vite.config.*.timestamp*',
|
||||
'**/vitest.config.*.timestamp*'
|
||||
@@ -181,5 +181,31 @@ export default defineConfig([
|
||||
{ disallowTypeAnnotations: false }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts'],
|
||||
ignores: ['browser_tests/tests/**/*.spec.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'Program',
|
||||
message: '.spec.ts files are only allowed under browser_tests/tests/'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['browser_tests/tests/**/*.test.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'Program',
|
||||
message:
|
||||
'.test.ts files are not allowed in browser_tests/tests/; use .spec.ts instead'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import type { KnipConfig } from 'knip'
|
||||
|
||||
const config: KnipConfig = {
|
||||
entry: [
|
||||
'{build,scripts}/**/*.{js,ts}',
|
||||
'src/assets/css/style.css',
|
||||
'src/main.ts',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts'
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}'],
|
||||
ignoreBinaries: ['only-allow', 'openapi-typescript'],
|
||||
workspaces: {
|
||||
'.': {
|
||||
entry: [
|
||||
'{build,scripts}/**/*.{js,ts}',
|
||||
'src/assets/css/style.css',
|
||||
'src/main.ts',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts'
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
|
||||
},
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/design-system': {
|
||||
entry: ['src/**/*.ts'],
|
||||
project: ['src/**/*.{js,ts}', '*.{js,ts,mts}']
|
||||
},
|
||||
'packages/registry-types': {
|
||||
entry: ['src/comfyRegistryTypes.ts'],
|
||||
project: ['src/**/*.{js,ts}']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify/json',
|
||||
@@ -23,7 +38,7 @@ const config: KnipConfig = {
|
||||
ignore: [
|
||||
// Auto generated manager types
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'src/types/comfyRegistryTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts'
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.28.2",
|
||||
"version": "1.28.3",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -38,7 +38,8 @@
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"storybook": "nx storybook -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
"build-storybook": "storybook build",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
@@ -106,6 +107,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@iconify/json": "^2.2.380",
|
||||
"@primeuix/forms": "0.0.2",
|
||||
@@ -158,4 +160,4 @@
|
||||
"zod": "^3.23.8",
|
||||
"zod-validation-error": "^3.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
packages/registry-types/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@comfyorg/registry-types",
|
||||
"version": "1.0.0",
|
||||
"description": "Comfy Registry API TypeScript types",
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/comfyRegistryTypes.ts"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:types"
|
||||
]
|
||||
}
|
||||
}
|
||||
19469
packages/registry-types/src/comfyRegistryTypes.ts
generated
Normal file
8
packages/registry-types/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
22
packages/shared-frontend-utils/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@comfyorg/shared-frontend-utils",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Shared frontend utils for ComfyUI Frontend",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import type { operations } from '@comfyorg/registry-types'
|
||||
|
||||
export function formatCamelCase(str: string): string {
|
||||
// Check if the string is camel case
|
||||
@@ -194,27 +193,6 @@ export function isValidUrl(url: string): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const hasAnnotation = (filepath: string): boolean =>
|
||||
/\[(input|output|temp)\]/i.test(filepath)
|
||||
|
||||
const createAnnotation = (filepath: string, rootFolder = 'input'): string =>
|
||||
!hasAnnotation(filepath) && rootFolder !== 'input' ? ` [${rootFolder}]` : ''
|
||||
|
||||
const createPath = (filename: string, subfolder = ''): string =>
|
||||
subfolder ? `${subfolder}/${filename}` : filename
|
||||
|
||||
/** Creates annotated filepath in format used by folder_paths.py */
|
||||
export function createAnnotatedPath(
|
||||
item: string | ResultItem,
|
||||
options: { rootFolder?: string; subfolder?: string } = {}
|
||||
): string {
|
||||
const { rootFolder = 'input', subfolder } = options
|
||||
if (typeof item === 'string')
|
||||
return `${createPath(item, subfolder)}${createAnnotation(item, rootFolder)}`
|
||||
return `${createPath(item.filename ?? '', item.subfolder)}${
|
||||
item.type ? createAnnotation(item.type, rootFolder) : ''
|
||||
}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filepath into its filename and subfolder components.
|
||||
@@ -1,8 +1,5 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { electronAPI } from './envUtil'
|
||||
import { isValidUrl } from './formatUtil'
|
||||
|
||||
const VALID_STATUS_CODES = [200, 201, 301, 302, 307, 308]
|
||||
export const checkUrlReachable = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
@@ -14,17 +11,6 @@ export const checkUrlReachable = async (url: string): Promise<boolean> => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mirror is reachable from the electron App.
|
||||
* @param mirror - The mirror to check.
|
||||
* @returns True if the mirror is reachable, false otherwise.
|
||||
*/
|
||||
export const checkMirrorReachable = async (mirror: string) => {
|
||||
return (
|
||||
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is likely in mainland China by:
|
||||
* 1. Checking navigator.language
|
||||
8
packages/shared-frontend-utils/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
23
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
'@comfyorg/registry-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry-types
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:packages/tailwind-utils
|
||||
@@ -368,6 +371,18 @@ importers:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.2
|
||||
|
||||
packages/registry-types: {}
|
||||
|
||||
packages/shared-frontend-utils:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.2
|
||||
|
||||
packages/tailwind-utils:
|
||||
dependencies:
|
||||
clsx:
|
||||
@@ -6507,8 +6522,8 @@ packages:
|
||||
vue-component-type-helpers@3.0.7:
|
||||
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
|
||||
|
||||
vue-component-type-helpers@3.0.8:
|
||||
resolution: {integrity: sha512-WyR30Eq15Y/+odrUUMax6FmPbZwAp/HnC7qgR1r3lVFAcqwQ4wUoV79Mbh4SxDy3NiqDa+G4TOKD5xXSgBHo5A==}
|
||||
vue-component-type-helpers@3.1.0:
|
||||
resolution: {integrity: sha512-cC1pYNRZkSS1iCvdlaMbbg2sjDwxX098FucEjtz9Yig73zYjWzQsnMe5M9H8dRNv55hAIDGUI29hF2BEUA4FMQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -9066,7 +9081,7 @@ snapshots:
|
||||
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.0.8
|
||||
vue-component-type-helpers: 3.1.0
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -13864,7 +13879,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.0.7: {}
|
||||
|
||||
vue-component-type-helpers@3.0.8: {}
|
||||
vue-component-type-helpers@3.1.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
77
scripts/cicd/check-json.sh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [--debug]" >&2
|
||||
}
|
||||
|
||||
debug=0
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--debug)
|
||||
debug=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Validate JSON syntax in tracked files using jq
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "Error: jq is required but not installed" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
EXCLUDE_PATTERNS=(
|
||||
'**/tsconfig*.json'
|
||||
)
|
||||
|
||||
if [ -n "${JSON_LINT_EXCLUDES:-}" ]; then
|
||||
# shellcheck disable=SC2206
|
||||
EXCLUDE_PATTERNS+=( ${JSON_LINT_EXCLUDES} )
|
||||
fi
|
||||
|
||||
pathspecs=(-- '*.json')
|
||||
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
|
||||
if [[ ${pattern:0:1} == ':' ]]; then
|
||||
pathspecs+=("$pattern")
|
||||
else
|
||||
pathspecs+=(":(glob,exclude)${pattern}")
|
||||
fi
|
||||
done
|
||||
|
||||
mapfile -t json_files < <(git ls-files "${pathspecs[@]}")
|
||||
|
||||
if [ "${#json_files[@]}" -eq 0 ]; then
|
||||
echo 'No JSON files found.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$debug" -eq 1 ]; then
|
||||
echo 'JSON files to validate:'
|
||||
printf ' %s\n' "${json_files[@]}"
|
||||
fi
|
||||
|
||||
failed=0
|
||||
for file in "${json_files[@]}"; do
|
||||
if ! jq -e . "$file" >/dev/null; then
|
||||
echo "Invalid JSON syntax: $file" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo 'JSON validation failed.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 'All JSON files are valid.'
|
||||
247
scripts/cicd/pr-storybook-deploy-and-comment.sh
Executable file
@@ -0,0 +1,247 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Deploy Storybook to Cloudflare Pages and comment on PR
|
||||
# Usage: ./pr-storybook-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="<!-- STORYBOOK_BUILD_STATUS -->"
|
||||
|
||||
# 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
|
||||
|
||||
# Deploy Storybook report, WARN: ensure inputs are sanitized before calling this function
|
||||
deploy_storybook() {
|
||||
dir="$1"
|
||||
branch="$2"
|
||||
|
||||
[ ! -d "$dir" ] && echo "failed" && return
|
||||
|
||||
project="comfy-storybook"
|
||||
|
||||
echo "Deploying Storybook 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 is already sanitized, use it directly
|
||||
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
|
||||
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
|
||||
# Check if this is a version-bump branch
|
||||
IS_VERSION_BUMP="false"
|
||||
if echo "$BRANCH_NAME" | grep -q "^version-bump-"; then
|
||||
IS_VERSION_BUMP="true"
|
||||
fi
|
||||
|
||||
# Post starting comment with appropriate message
|
||||
if [ "$IS_VERSION_BUMP" = "true" ]; then
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
|
||||
|
||||
⏰ Started at: $START_TIME UTC
|
||||
|
||||
### 🚀 Building Storybook
|
||||
- 📦 Installing dependencies...
|
||||
- 🔧 Building Storybook components...
|
||||
- 🎨 Running Chromatic visual tests...
|
||||
|
||||
---
|
||||
⏱️ Please wait while the Storybook build is in progress...
|
||||
EOF
|
||||
)
|
||||
else
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
|
||||
|
||||
⏰ Started at: $START_TIME UTC
|
||||
|
||||
### 🚀 Building Storybook
|
||||
- 📦 Installing dependencies...
|
||||
- 🔧 Building Storybook components...
|
||||
- 🌐 Preparing deployment to Cloudflare Pages...
|
||||
|
||||
---
|
||||
⏱️ Please wait while the Storybook build is in progress...
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
post_comment "$comment"
|
||||
|
||||
elif [ "$STATUS" = "completed" ]; then
|
||||
# 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 Storybook build in: $(pwd)/storybook-static"
|
||||
|
||||
# Deploy Storybook if build exists
|
||||
deployment_url="Not deployed"
|
||||
if [ -d "storybook-static" ]; then
|
||||
echo "Found Storybook build, deploying..."
|
||||
url=$(deploy_storybook "storybook-static" "$cloudflare_branch")
|
||||
if [ "$url" != "failed" ] && [ -n "$url" ]; then
|
||||
deployment_url="[View Storybook]($url)"
|
||||
else
|
||||
deployment_url="Deployment failed"
|
||||
fi
|
||||
else
|
||||
echo "Storybook build not found at storybook-static"
|
||||
fi
|
||||
|
||||
# Get workflow conclusion from environment or default to success
|
||||
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
|
||||
WORKFLOW_URL="${WORKFLOW_URL:-}"
|
||||
|
||||
# Generate completion comment based on conclusion
|
||||
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
status_icon="✅"
|
||||
status_text="Build completed successfully!"
|
||||
footer_text="🎉 Your Storybook is ready for review!"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
|
||||
status_icon="⏭️"
|
||||
status_text="Build skipped."
|
||||
footer_text="ℹ️ Chromatic was skipped for this PR."
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
|
||||
status_icon="🚫"
|
||||
status_text="Build cancelled."
|
||||
footer_text="ℹ️ The Chromatic run was cancelled."
|
||||
else
|
||||
status_icon="❌"
|
||||
status_text="Build failed!"
|
||||
footer_text="⚠️ Please check the workflow logs for error details."
|
||||
fi
|
||||
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
$status_icon **$status_text**
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
|
||||
|
||||
### 🔗 Links
|
||||
- [📊 View Workflow Run]($WORKFLOW_URL)"
|
||||
|
||||
# Add deployment status
|
||||
if [ "$deployment_url" != "Not deployed" ]; then
|
||||
if [ "$deployment_url" = "Deployment failed" ]; then
|
||||
comment="$comment
|
||||
- ❌ Storybook deployment failed"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
comment="$comment
|
||||
- 🎨 $deployment_url"
|
||||
else
|
||||
comment="$comment
|
||||
- ⚠️ Build failed - $deployment_url"
|
||||
fi
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
comment="$comment
|
||||
- ⏭️ Storybook deployment skipped (build did not succeed)"
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
|
||||
---
|
||||
$footer_text"
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
@@ -1,12 +1,15 @@
|
||||
import * as fs from 'fs'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import {
|
||||
formatCamelCase,
|
||||
normalizeI18nKey
|
||||
} from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
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 { formatCamelCase, normalizeI18nKey } from '../src/utils/formatUtil'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const commandsPath = './src/locales/en/commands.json'
|
||||
|
||||
@@ -3,8 +3,8 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
import { normalizeI18nKey } from '../src/utils/formatUtil'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
|
||||
@@ -93,6 +93,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
@@ -103,7 +104,6 @@ import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -189,8 +189,8 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const allNodes = computed(() =>
|
||||
Array.from(vueNodeLifecycle.vueNodeData.value.values())
|
||||
const allNodes = computed((): VueNodeData[] =>
|
||||
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -225,7 +225,6 @@ watch(
|
||||
for (const n of comfyApp.graph.nodes) {
|
||||
if (!n.widgets) continue
|
||||
for (const w of n.widgets) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
if (w[IS_CONTROL_WIDGET]) {
|
||||
updateControlWidgetLabel(w)
|
||||
if (w.linkedWidgets) {
|
||||
@@ -293,45 +292,36 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Update node slot errors
|
||||
// Update node slot errors for LiteGraph nodes
|
||||
// (Vue nodes read from store directly)
|
||||
watch(
|
||||
() => executionStore.lastNodeErrors,
|
||||
(lastNodeErrors) => {
|
||||
const removeSlotError = (node: LGraphNode) => {
|
||||
if (!comfyApp.graph) return
|
||||
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
// Clear existing errors
|
||||
for (const slot of node.inputs) {
|
||||
delete slot.hasErrors
|
||||
}
|
||||
for (const slot of node.outputs) {
|
||||
delete slot.hasErrors
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
removeSlotError(node)
|
||||
const nodeErrors = lastNodeErrors?.[node.id]
|
||||
if (!nodeErrors) continue
|
||||
|
||||
const validErrors = nodeErrors.errors.filter(
|
||||
(error) => error.extra_info?.input_name !== undefined
|
||||
)
|
||||
const slotErrorsChanged =
|
||||
validErrors.length > 0 &&
|
||||
validErrors.some((error) => {
|
||||
const inputName = error.extra_info!.input_name!
|
||||
const inputIndex = node.findInputSlot(inputName)
|
||||
if (inputIndex !== -1) {
|
||||
node.inputs[inputIndex].hasErrors = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Trigger Vue node data update if slot errors changed
|
||||
if (slotErrorsChanged && comfyApp.graph.onTrigger) {
|
||||
comfyApp.graph.onTrigger('node:slot-errors:changed', {
|
||||
nodeId: node.id
|
||||
})
|
||||
}
|
||||
validErrors.forEach((error) => {
|
||||
const inputName = error.extra_info!.input_name!
|
||||
const inputIndex = node.findInputSlot(inputName)
|
||||
if (inputIndex !== -1) {
|
||||
node.inputs[inputIndex].hasErrors = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
comfyApp.canvas.draw(true, true)
|
||||
@@ -364,7 +354,6 @@ const loadCustomNodesI18n = async () => {
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
|
||||
@@ -125,50 +125,43 @@ watch(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Set up event listeners only after the widget is mounted and visible
|
||||
const setupDOMEventListeners = () => {
|
||||
if (!isDOMWidget(widget) || !widgetState.visible) return
|
||||
|
||||
if (widget.element.blur) {
|
||||
useEventListener(document, 'mousedown', (event) => {
|
||||
if (!widget.element.contains(event.target as HTMLElement)) {
|
||||
widget.element.blur()
|
||||
}
|
||||
})
|
||||
useEventListener(document, 'mousedown', (event) => {
|
||||
if (!isDOMWidget(widget) || !widgetState.visible || !widget.element.blur) {
|
||||
return
|
||||
}
|
||||
if (!widget.element.contains(event.target as HTMLElement)) {
|
||||
widget.element.blur()
|
||||
}
|
||||
})
|
||||
|
||||
for (const evt of widget.options.selectOn ?? ['focus', 'click']) {
|
||||
useEventListener(widget.element, evt, () => {
|
||||
onMounted(() => {
|
||||
if (!isDOMWidget(widget)) {
|
||||
return
|
||||
}
|
||||
useEventListener(
|
||||
widget.element,
|
||||
widget.options.selectOn ?? ['focus', 'click'],
|
||||
() => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
lgCanvas?.selectNode(widget.node)
|
||||
lgCanvas?.bringToFront(widget.node)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners when widget becomes visible
|
||||
watch(
|
||||
() => widgetState.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
setupDOMEventListeners()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const inputSpec = widget.node.constructor.nodeData
|
||||
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
|
||||
|
||||
// Mount DOM element when widget is or becomes visible
|
||||
const mountElementIfVisible = () => {
|
||||
if (widgetState.visible && isDOMWidget(widget) && widgetElement.value) {
|
||||
// Only append if not already a child
|
||||
if (!widgetElement.value.contains(widget.element)) {
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
if (!(widgetState.visible && isDOMWidget(widget) && widgetElement.value)) {
|
||||
return
|
||||
}
|
||||
// Only append if not already a child
|
||||
if (widgetElement.value.contains(widget.element)) {
|
||||
return
|
||||
}
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
|
||||
// Check on mount - but only after next tick to ensure visibility is calculated
|
||||
|
||||
@@ -60,8 +60,8 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import type { UVMirror } from '@/constants/uvMirrors'
|
||||
import { st } from '@/i18n'
|
||||
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { checkMirrorReachable } from '@/utils/networkUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const FILE_URL_SCHEME = 'file://'
|
||||
|
||||